forked from cory/tildefriends
Compare commits
845 Commits
Author | SHA1 | Date | |
---|---|---|---|
af13bfc920 | |||
e24fd92f85 | |||
7e27cefe6a | |||
450cf6424e | |||
54898d3dbb | |||
dd851a2b25 | |||
4c6b44eb30 | |||
74a3efe78d | |||
51301fc49e | |||
02dd8c3dd0 | |||
26a778c3b2 | |||
9fecbd97e8 | |||
e1383e3903 | |||
47532b8512 | |||
3c4959433a | |||
e921b4a86a | |||
b23b0ca239 | |||
191b45f054 | |||
15d0383349 | |||
d2485583fd | |||
2b94704916 | |||
85ac6c215a | |||
e83e665db9 | |||
645aafef16 | |||
152c893a6f | |||
7c130dda56 | |||
2d82dad806 | |||
e8ac5b759d | |||
4833d18968 | |||
6eafded1f6 | |||
7b440b720e | |||
e20ba7384f | |||
45231c6ede | |||
35475defb5 | |||
8741841f27 | |||
5282d19b55 | |||
d9782aa0fb | |||
9751facfb4 | |||
e0110203e7 | |||
088b44cc2c | |||
8f63bcbfbf | |||
c8029388c9 | |||
d9c4d847a1 | |||
df9d9425ec | |||
90bb3c684e | |||
9c81b6de8a | |||
6383498041 | |||
daeb88785d | |||
dcea08f73b | |||
b252b921f8 | |||
172826bf13 | |||
060f1980f5 | |||
e223d35252 | |||
99dba1a4c6 | |||
b52026c81f | |||
47b8c86426 | |||
2e55c68648 | |||
b7362dd84d | |||
01637b31e1 | |||
0e9a39608a | |||
79404e4d41 | |||
35c21fbdaf | |||
8c7bd7dc11 | |||
09ad4f0320 | |||
d96b836bef | |||
59b2ffaf95 | |||
f1b55ddd64 | |||
85acac3a30 | |||
befff5c1e5 | |||
d72ba81a67 | |||
fef88e2032 | |||
20557e8ce4 | |||
99c905e908 | |||
d7b58ee2c5 | |||
faca2d387b | |||
358d02d97f | |||
b66dac7465 | |||
f7d201859a | |||
61d2ef5469 | |||
ac994b9c62 | |||
264dcbc331 | |||
e5425c0ffb | |||
e10803de68 | |||
07b1a0e403 | |||
6ed2c702d8 | |||
5c1c33d33e | |||
70d37c88b5 | |||
1ba37d95b5 | |||
0d82198849 | |||
39927e75f2 | |||
e6fd33b969 | |||
e8fe32d5af | |||
bfc8bb864d | |||
9179746763 | |||
d0177d24cb | |||
0573008c9c | |||
9506f518c2 | |||
0f0ae9153b | |||
09c7c8ac64 | |||
5e2dfff148 | |||
958b47548d | |||
16155ef746 | |||
5755b61ea6 | |||
353847a77f | |||
bdf64edeb8 | |||
b5768dd927 | |||
3e5abf3a4d | |||
d3029639de | |||
d21d7e4add | |||
afde69b5d9 | |||
3319df3df0 | |||
1102feaac3 | |||
deede728be | |||
fc3dd84122 | |||
9239441d73 | |||
b984811851 | |||
1c52446331 | |||
b6dffa8e66 | |||
315d650d27 | |||
07c121044a | |||
f3169afcf5 | |||
c371fc2a8e | |||
6889e11fd1 | |||
fb73fd0afc | |||
6fcebd7a08 | |||
15ea62a546 | |||
b0cd58f5aa | |||
7fe8f66fd3 | |||
68ca99e9d9 | |||
a2542c658b | |||
eb203c7e62 | |||
6ef466f3ed | |||
5074246462 | |||
73bbcebddb | |||
18128303b6 | |||
c4a2d790a3 | |||
c1ec150696 | |||
f4b856df15 | |||
85b87553dd | |||
5decdf3afa | |||
a4acee4939 | |||
d06aea2831 | |||
ae0a8b0a33 | |||
f0452704a1 | |||
b8b1f1ba80 | |||
caf7478da4 | |||
0e40ba78a4 | |||
d1eac6c9eb | |||
8f5201b2bc | |||
6022001d66 | |||
f018c367ed | |||
48c47f097a | |||
39ac215b5a | |||
7d562ce85c | |||
51b317233a | |||
87ce715011 | |||
ef5afc1e23 | |||
486212f22a | |||
0e8867dd6e | |||
ca28b5ca82 | |||
19e26c1759 | |||
790f6643a4 | |||
2158ad3c0b | |||
d904d8922f | |||
da50792500 | |||
b4629acc48 | |||
0cf4118330 | |||
dd61a6ecc3 | |||
8e6f1284e1 | |||
813d3cd492 | |||
f421606e21 | |||
1ccb9183b4 | |||
7d9b627f37 | |||
3038138909 | |||
2ca08d21e4 | |||
478e96fc5f | |||
e237c7ea1d | |||
bf9ff088fd | |||
e073ebedd1 | |||
10d4ae7dcc | |||
5b8bdbb3e4 | |||
c807e21c6b | |||
cc92d0e316 | |||
09c396d5a3 | |||
bc5bbca951 | |||
ed4faedcd7 | |||
251556ebed | |||
1324afb459 | |||
1119804fc2 | |||
cdf6440197 | |||
8727fe00af | |||
7da7890bb6 | |||
706bd2c51f | |||
acabec940e | |||
470b998b61 | |||
80fad05f23 | |||
07a912fb9a | |||
e9d83262c4 | |||
74323c22f9 | |||
2614e89b1b | |||
e092fe1399 | |||
9cbe895cb8 | |||
b0b0f74e83 | |||
d9eaa92c37 | |||
566d07117e | |||
2bffdb1168 | |||
1359b48c9f | |||
a69fb5eeac | |||
38e313350e | |||
5052dc04f2 | |||
9ef3a3aca0 | |||
7b91a2ec37 | |||
2926f855a1 | |||
639419db60 | |||
54747c127c | |||
791c3dd787 | |||
b00d75ab7c | |||
956ea0df56 | |||
30014040e7 | |||
ab055c3394 | |||
1e37eeea05 | |||
84aec0278d | |||
06642f58c5 | |||
e6d44b32f4 | |||
1f3f6e2b92 | |||
8f2d3e3bcd | |||
2df2fc5792 | |||
20b0337e0a | |||
e86b9dae48 | |||
71de897419 | |||
3edfaf9137 | |||
19c1784864 | |||
0d9fac7363 | |||
2fb91fccc0 | |||
24e1ab12ab | |||
10ea885d8d | |||
ec65faa12d | |||
53692a1ea8 | |||
ebef51b4ea | |||
a94d6f9271 | |||
3d2c88c201 | |||
bdeee7fc0e | |||
33a037e0ea | |||
2dc2d9ebf6 | |||
9748f0ed8b | |||
d6be2f7d54 | |||
63615747a7 | |||
fbb657a85c | |||
bdac0c7879 | |||
54dde76a8a | |||
2bbe22bc7a | |||
ad8532f7ac | |||
602941104e | |||
d38b41687c | |||
08125cd1e8 | |||
2ce2097a3f | |||
a5da17e1b1 | |||
2b0962f087 | |||
37173cce4c | |||
37edbd9824 | |||
a32bb02223 | |||
2ab1b84432 | |||
52ae19220c | |||
10bfa65a4e | |||
2a3b1a1e33 | |||
f74f4f6da9 | |||
12a8b7a058 | |||
400f07660f | |||
d532795b7f | |||
6064ed6a3a | |||
2c1a43df2e | |||
bf72782c9f | |||
63dcab30c3 | |||
50e48af7c4 | |||
9127a18ff0 | |||
61ff466908 | |||
1c10768aa4 | |||
992b123853 | |||
f736756b20 | |||
28d73f5b37 | |||
262b0e5e52 | |||
1e3807bcb9 | |||
2ed3295f77 | |||
8c9d687d50 | |||
b8b694864e | |||
961109635b | |||
86bc46a11e | |||
a6a6fe75ec | |||
f55f863867 | |||
4ce988d00b | |||
1548a8a852 | |||
a9551b057b | |||
88c7d91858 | |||
53cb80ebf7 | |||
1f67343d75 | |||
4bea8bb6ba | |||
8e1461b3f1 | |||
90b513d070 | |||
8a2d3d4669 | |||
1741403206 | |||
980db880cc | |||
507a62539d | |||
6b5d73ed5c | |||
1f77df7a90 | |||
fa87462405 | |||
a5f9f927e6 | |||
b35d74ce36 | |||
ac60be14a5 | |||
beda047eb0 | |||
f6742bebf3 | |||
7f334ad783 | |||
ffda896308 | |||
b2fbe9dfac | |||
6d6c41bffa | |||
e04d137af5 | |||
ec52e62908 | |||
6104af0d70 | |||
0ca05e297d | |||
e0dcec074c | |||
a8cecb5c64 | |||
582ee0e4d7 | |||
0ba54c2b7b | |||
3c288f7f68 | |||
c692b1b1f8 | |||
7091b6e6a5 | |||
48cd08e095 | |||
ef7f9db9c4 | |||
0092f24fb9 | |||
f9db1a7acf | |||
da75ad9337 | |||
7318ddd70e | |||
ab75ec07f8 | |||
0a6b842179 | |||
5d5ff121f9 | |||
adefa76dfd | |||
2420869e7f | |||
f841ca4399 | |||
433db904cd | |||
c067623740 | |||
dab7050899 | |||
77df158178 | |||
0af1bcf110 | |||
e05302ac99 | |||
ce6cc82d64 | |||
85a2bc3f0f | |||
3285d93576 | |||
0f11f497ed | |||
45a5202456 | |||
ce0b4de5a1 | |||
134b2556ad | |||
67d34bf70e | |||
73863f9418 | |||
0cbc1a650b | |||
9248dfd97e | |||
b8f54f324f | |||
3269c7ca45 | |||
8a1b4cceec | |||
7cd925feca | |||
f6ae15c4dc | |||
6ed057089b | |||
a5ba014736 | |||
4d4cc92150 | |||
3b00b31e87 | |||
3c687dc780 | |||
987b2d539a | |||
80a1e94da4 | |||
69253432b8 | |||
53e4f4341c | |||
6ff33191bb | |||
513eb88a53 | |||
3506d9dec1 | |||
c09e043812 | |||
4c01f23ee8 | |||
ff06e91ac8 | |||
8ed359327c | |||
a66a70324d | |||
67fbbd4a8d | |||
235fc9b8f9 | |||
f257cccded | |||
5342ddb2bd | |||
7cba1b21ad | |||
120ed36552 | |||
a9f6593979 | |||
ca6d042ed6 | |||
ae4c2aef69 | |||
ed1c85288c | |||
71151a511d | |||
7f35f01b88 | |||
1d13c25ded | |||
09ddfffa6b | |||
d9aee6d05f | |||
94d7d2e3e0 | |||
f748fcf1f7 | |||
9c89c2f717 | |||
d88752d840 | |||
bb565aeb23 | |||
c1015a8bdd | |||
181b21080c | |||
577efb6b7a | |||
1a45113e0c | |||
c49da3db07 | |||
b406501263 | |||
c30b3bbb64 | |||
82f9859c57 | |||
4080266fa3 | |||
210149d6be | |||
c2eb439574 | |||
23a6a24288 | |||
932989ee9c | |||
1a91b56a1d | |||
8115881c08 | |||
7fe3bddeba | |||
376094452e | |||
cd8b32b3ca | |||
2251406bd1 | |||
d8fb956c14 | |||
b1ff215ad7 | |||
f9b4ab91c0 | |||
72952e0c39 | |||
acc14f7318 | |||
d48b8b0ae1 | |||
7ff09ed005 | |||
672fb8fcf4 | |||
fe6d492347 | |||
b65706ffc4 | |||
cb44d408cd | |||
880ab7fdde | |||
be6f24b3ee | |||
902287292d | |||
c4b4103802 | |||
c664f7808f | |||
6b267e472e | |||
dae66424dc | |||
170d5a9621 | |||
179da40a4b | |||
9b696503de | |||
efdecc6017 | |||
1a35a6a161 | |||
041e63ac70 | |||
046bf7e2a9 | |||
20ebdea9d1 | |||
1e84b74ced | |||
cdbc2d48f7 | |||
1140c5ddc7 | |||
0d23294d42 | |||
2dc7f58c80 | |||
de59a7f338 | |||
205f0df1b4 | |||
5ed9a77d38 | |||
ae545e7b2b | |||
e49b54207a | |||
c1df77bb96 | |||
98a7753a55 | |||
d3d4b1a13c | |||
241dfdb90e | |||
6ba41f03da | |||
9ef9dadbb8 | |||
06529fddfb | |||
f015c8727d | |||
3a5ae4c228 | |||
b12f8f9da8 | |||
1abc611e54 | |||
6a4559c580 | |||
54ebd0e643 | |||
60d1ea9d39 | |||
16dbc7617c | |||
a37ad69c8b | |||
3bbeec8ece | |||
de398786be | |||
b8fa59d3ec | |||
704ed737a9 | |||
04ae7a2540 | |||
954e0227d4 | |||
1ab79adb27 | |||
c7ee998b21 | |||
f53ce584e3 | |||
70866e03c8 | |||
656ab7beb6 | |||
1dec53821e | |||
c0a14a738e | |||
d9c5f74d62 | |||
e5dcff0200 | |||
25cc3d7c3a | |||
1cffc5ec24 | |||
5e72b111d9 | |||
3cdfc7af2b | |||
113a82b382 | |||
5b3ae3f006 | |||
c0ecdaae12 | |||
828f61c4e9 | |||
775f00c69c | |||
eadda41518 | |||
8279ec5e9e | |||
ab1f47ee9a | |||
d216d96144 | |||
88592886ca | |||
7077e69bf7 | |||
f983c3d987 | |||
26691051a5 | |||
fe33903e2e | |||
6ea6ae2322 | |||
bb0a840dc6 | |||
52f5bb408f | |||
ee1e1b11af | |||
56db6a8e4d | |||
3b676d967e | |||
97b7643049 | |||
c3fb80a1c8 | |||
7c29c1e18e | |||
4c0dc6ad04 | |||
6cfe0ca4fb | |||
46d3e8f567 | |||
3729346961 | |||
357d944a8d | |||
69991abbb4 | |||
0518c5dd21 | |||
e4c182a6fa | |||
8edc9aaa63 | |||
4525ee9cca | |||
3464f1d189 | |||
fc9c3982c2 | |||
d70dba021a | |||
41590921c3 | |||
4d629c45eb | |||
39f05b6bf5 | |||
58196c4ac0 | |||
eca3696740 | |||
fbfbd6a6b4 | |||
353f2ccc13 | |||
6628a5c420 | |||
1973030774 | |||
787e439524 | |||
fab2c17b43 | |||
1775fdd6b5 | |||
5cc7641788 | |||
6c2fd6d90f | |||
24530e1158 | |||
0bd1463a6b | |||
6728727e89 | |||
ac960a98bf | |||
f787eb077b | |||
b2ecc24e85 | |||
3c82a87968 | |||
f06753b56e | |||
41afc3bdd6 | |||
f764007fc6 | |||
ae5560f33a | |||
e6532979aa | |||
3078536245 | |||
1efc0fd73b | |||
aee99af953 | |||
a154b1c2f6 | |||
7f350a3d87 | |||
982b5817a2 | |||
52f744e106 | |||
7f9c01a9bb | |||
fb3ad0d95d | |||
fe5a6033ef | |||
ff2a0f0c3f | |||
66ea0dadd0 | |||
474ff9cd74 | |||
718383205b | |||
c9e01f220d | |||
f69e74ce53 | |||
b5c6cac048 | |||
515999e570 | |||
ab58f42f0c | |||
af3e96c7e8 | |||
782b5593d5 | |||
d892c9e734 | |||
d3e9041b15 | |||
3a40722c89 | |||
b42b5d11fa | |||
2d8a956c14 | |||
ed6550a4cd | |||
e1ca715c64 | |||
4e3bf99327 | |||
b5b6ed8ba5 | |||
4293e75082 | |||
927e2b7060 | |||
83bdbbb4dc | |||
1dc6084d2d | |||
ae894eaa9d | |||
a8ced8757c | |||
653e16b059 | |||
9c90b2bc1d | |||
cf61e68713 | |||
7b53c95832 | |||
c8e09d8637 | |||
cb9edaacd4 | |||
2992b7e955 | |||
5622db92a7 | |||
58f459fb3b | |||
3bc428a83e | |||
0556af3e07 | |||
2882af1c05 | |||
b06c657ef0 | |||
04ec425c9c | |||
842633f6d1 | |||
e5160b9d2c | |||
e8fb73fdf9 | |||
939e13c3c8 | |||
787e929747 | |||
b688a89b66 | |||
7848b5e560 | |||
87224d2bb6 | |||
2826efea56 | |||
0d1b231344 | |||
804359d12e | |||
11ad344e52 | |||
d802c0023b | |||
42fcfee042 | |||
a1d244567a | |||
00bdf1df4a | |||
9b2d4b393d | |||
5e0c20e432 | |||
352f33f5a1 | |||
efc5eb2aff | |||
7e9460f47c | |||
41cabad264 | |||
b488db9137 | |||
cb315c717b | |||
3381b588a1 | |||
7c2962afcf | |||
498a093cde | |||
07a87ff9de | |||
c138582638 | |||
011038a38a | |||
1bfa18b8d7 | |||
95f0b91a0e | |||
ffaaec5b37 | |||
ac0482d7f5 | |||
f4b46cc3a0 | |||
4bb095e81f | |||
5e92e2ffe1 | |||
a4a0745385 | |||
eb191254b0 | |||
e4e763b7a0 | |||
5ffc505ce2 | |||
1bdd67d659 | |||
483638a7e6 | |||
50bef73200 | |||
d4135f7133 | |||
557ae6ee5a | |||
07b4f2b08f | |||
9a75af8146 | |||
5b3c7dcecc | |||
6b20d69976 | |||
fbb61581c6 | |||
25d793e9e8 | |||
8f35004a01 | |||
e59eb66c1d | |||
412dce0a47 | |||
1aa4b0e590 | |||
ef9e42e030 | |||
059024452c | |||
39a1acaf38 | |||
8ecc07452e | |||
e85ee5766b | |||
91339dc8a7 | |||
7733cb2604 | |||
157209e9b5 | |||
cd51edcd8f | |||
a98a848bb7 | |||
c57b0a2f2f | |||
bf7d5c34f6 | |||
ea92fbdcea | |||
9f75346dd8 | |||
b5111efc29 | |||
0278aceb62 | |||
ec5d7c1a01 | |||
40216377f9 | |||
d062db2ba8 | |||
d3875cf738 | |||
fefb0f92bc | |||
0ddb86b5a8 | |||
d77c452120 | |||
e84ced6f79 | |||
e4d77679dc | |||
9fd4be0e4a | |||
7b32067b07 | |||
e1167b6854 | |||
25ee0a3561 | |||
4771810d6b | |||
ae10d3fa6f | |||
ac92b5f8de | |||
d0c89991be | |||
0a580b60b1 | |||
24116f498f | |||
5623cba7c3 | |||
bd81b2acf5 | |||
6c28ca738e | |||
b2a552b3e0 | |||
0f03701043 | |||
d470d6c398 | |||
98de9b037a | |||
df0bb102dc | |||
1734c88627 | |||
df94378b96 | |||
83fa488b8d | |||
1515525a1b | |||
0b5017b208 | |||
c864041fa0 | |||
e9e1a3e80d | |||
1ddaa7deb0 | |||
cf56078e25 | |||
a156cdea9f | |||
4637509b3d | |||
1ae9aaf752 | |||
11cd707382 | |||
1807264df5 | |||
e927ff915b | |||
b8068b9ed1 | |||
019ab99ecc | |||
c40a513876 | |||
dbfa9e5623 | |||
5e0304481b | |||
0bcc7d8c59 | |||
27c2f27708 | |||
e1f868730f | |||
7ba1e6980f | |||
35b7eb511a | |||
d51eb64c8e | |||
53aac8d23a | |||
9d105bdc1a | |||
873019f054 | |||
7f8155613c | |||
5f96eb18b2 | |||
32c7fcbbfa | |||
c28d4d9378 | |||
6af9c17efe | |||
ec9e9151dc | |||
86aa5e4d1e | |||
26150f98e1 | |||
bb81fc87b9 | |||
700d09c730 | |||
50d860183d | |||
1cf55d7d64 | |||
ae84f69025 | |||
49ffd1055e | |||
e2c25ab414 | |||
c02a3d3659 | |||
24cf18651a | |||
3eabe72299 | |||
e1448a1c3a | |||
df5dfa1539 | |||
23b15a8dc5 | |||
d550092bd3 | |||
4268963e70 | |||
3026443c1e | |||
4e359c3f5c | |||
4f1b31bce0 | |||
b980bb4946 | |||
fafc524c8c | |||
0cab3e7ed9 | |||
12010a84a3 | |||
f7974d2cef | |||
62e9dfea90 | |||
0bf216bb1a | |||
aba95d4fe8 | |||
5e205ac897 | |||
0f7472fa22 | |||
12ab2f4b85 | |||
f676cd937f | |||
263a59f6c5 | |||
2e1e4f90e7 | |||
6eed168b7d | |||
9f0315458f | |||
c590eb3a44 | |||
2e1b0089ae | |||
05b55c849a | |||
5fbe9c42bc | |||
3cddc524d1 | |||
efcada8e25 | |||
c616a16993 | |||
d4f7fdfc40 | |||
f760d48368 | |||
ba87f9acaa | |||
9faa4c9ca6 | |||
58b0b54785 | |||
e3b83c10db | |||
7f31798119 | |||
d9ffca81f8 | |||
1dd3b3c9aa | |||
8075bdfe99 | |||
b15cf901ad | |||
84a3d7348d | |||
00c1ec660e | |||
9e1bab03eb | |||
63c344112d | |||
18c90214a8 | |||
68cf3efcde | |||
308e24c9fa | |||
2fb7fceb0c | |||
fde7fb4270 | |||
03a2367532 | |||
08cd0ec878 | |||
0a01332d1f | |||
256c47c33c | |||
62ad08985c | |||
21ba7cb02c | |||
77ec1a0b2e | |||
07a0828626 | |||
08e32c0de4 | |||
f4f6bb8333 | |||
b1a6384ac1 | |||
786c83c57c | |||
843c53e15e | |||
470814f147 | |||
24a91219c1 | |||
d3e02470cd | |||
6d2b560c3d | |||
059392df8e | |||
3b4f0c1321 | |||
a09d159268 | |||
91ec68252d | |||
e85168ac53 | |||
35e0d8b68a | |||
cadcb236ee | |||
cfd5341a6b | |||
e922af4c55 | |||
45dfe34375 | |||
c78d3b0413 | |||
dd90fe4fbf | |||
be6a39bd15 | |||
da51e87774 | |||
5197eb91f7 | |||
87747c0b6b | |||
03cf347394 | |||
3487f335e5 | |||
8c0d380a4d | |||
b660abff7f | |||
5988fddf8d | |||
f268ca3adf | |||
cbc21cfbe6 | |||
cf195cdd44 | |||
85c5b4c4d6 | |||
7d8258c262 | |||
92c06b34a9 | |||
7012418b13 | |||
2b5a56abfe | |||
d8657866f5 | |||
a2851f8ade | |||
ff4c144be3 | |||
3650cd8350 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.svn
|
||||||
|
db.sqlite
|
||||||
|
out/**/*.o
|
||||||
|
out/**/*.d
|
59
COPYING
59
COPYING
@ -1,59 +0,0 @@
|
|||||||
Tilde Friends - An operating system for the web.
|
|
||||||
Copyright (C) 2014 Cory McWilliams <cory@unprompted.com>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Additional permission under GNU GPL version 3 section 7
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with libuv (or a modified version of that library), containing parts covered by
|
|
||||||
the terms of the MIT License, the licensors of this Program grant you
|
|
||||||
additional permission to convey the resulting work. {Corresponding Source for
|
|
||||||
a non-source form of such a combination shall include the source code for the
|
|
||||||
parts of libuv used as well as that of the covered work.}
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with QuickJS (or a modified version of that library), containing parts covered
|
|
||||||
by the terms of the MIT License, the licensors of this Program grant you
|
|
||||||
additional permission to convey the resulting work. {Corresponding Source for
|
|
||||||
a non-source form of such a combination shall include the source code for the
|
|
||||||
parts of QuickJS used as well as that of the covered work.}
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with libsodium (or a modified version of that library), containing parts
|
|
||||||
covered by the terms of the ISC License, the licensors of this Program grant
|
|
||||||
you additional permission to convey the resulting work. {Corresponding Source
|
|
||||||
for a non-source form of such a combination shall include the source code for
|
|
||||||
the parts of libsodium used as well as that of the covered work.}
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with xopt (or a modified version of that library), containing parts covered by
|
|
||||||
the terms of the Apache License 2.0, the licensors of this Program grant you
|
|
||||||
additional permission to convey the resulting work. {Corresponding Source for
|
|
||||||
a non-source form of such a combination shall include the source code for the
|
|
||||||
parts of xopt used as well as that of the covered work.}
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with crypt_blowfish (or a modified version of that library), containing parts
|
|
||||||
covered by the terms of the MIT License, the licensors of this Program grant
|
|
||||||
you additional permission to convey the resulting work. {Corresponding Source
|
|
||||||
for a non-source form of such a combination shall include the source code for
|
|
||||||
the parts of crypt_blowfish used as well as that of the covered work.}
|
|
||||||
|
|
||||||
If you modify this Program, or any covered work, by linking or combining it
|
|
||||||
with base64c (or a modified version of that library), containing parts covered
|
|
||||||
by the terms of the BSD 3-Clause License, the licensors of this Program grant
|
|
||||||
you additional permission to convey the resulting work. {Corresponding Source
|
|
||||||
for a non-source form of such a combination shall include the source code for
|
|
||||||
the parts of base64c used as well as that of the covered work.}
|
|
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM bitnami/minideb:bullseye 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
|
||||||
|
|
||||||
|
COPY --from=build /app/out/release/tildefriends /app/out/release/tildefriends
|
||||||
|
COPY --from=build /app/apps /app/apps
|
||||||
|
COPY --from=build /app/core /app/core
|
||||||
|
WORKDIR /app
|
||||||
|
EXPOSE 12345
|
||||||
|
ENTRYPOINT ["/app/out/release/tildefriends"]
|
680
LICENSE
680
LICENSE
@ -1,661 +1,19 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
Copyright 2014 Cory McWilliams
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
this software and associated documentation files (the "Software"), to deal in
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
the Software without restriction, including without limitation the rights to
|
||||||
of this license document, but changing it is not allowed.
|
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||||
|
of the Software, and to permit persons to whom the Software is furnished to do
|
||||||
Preamble
|
so, subject to the following conditions:
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
The above copyright notice and this permission notice shall be included in all
|
||||||
software and other kinds of works, specifically designed to ensure
|
copies or substantial portions of the Software.
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
The licenses for most software and other practical works are designed
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
to take away your freedom to share and change the works. By contrast,
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
share and change all versions of a program--to make sure it remains free
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
software for all its users.
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<http://www.gnu.org/licenses/>.
|
|
||||||
|
537
Makefile
537
Makefile
@ -1,69 +1,159 @@
|
|||||||
|
.ONESHELL:
|
||||||
|
.DELETE_ON_ERROR:
|
||||||
|
MAKEFLAGS += --warn-undefined-variables
|
||||||
|
MAKEFLAGS += --no-builtin-rules
|
||||||
|
|
||||||
|
VERSION_CODE := 11
|
||||||
|
VERSION_NUMBER := 0.0.11
|
||||||
|
VERSION_NAME := Be nothing, and you will have everything to give to others.
|
||||||
|
|
||||||
PROJECT = tildefriends
|
PROJECT = tildefriends
|
||||||
BUILD_DIR ?= out
|
BUILD_DIR ?= out
|
||||||
|
BUILD_TYPES := debug release windebug winrelease androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64
|
||||||
|
UNAME_M := $(shell uname -m)
|
||||||
|
|
||||||
COMMON_CFLAGS = \
|
CFLAGS += \
|
||||||
-Wall \
|
-Wall \
|
||||||
-Werror \
|
|
||||||
-Wextra \
|
-Wextra \
|
||||||
-Wno-unused-parameter \
|
-Wno-unused-parameter \
|
||||||
-Wno-cast-function-type \
|
-Wno-cast-function-type \
|
||||||
-MMD \
|
-MMD \
|
||||||
-ffunction-sections \
|
-ffunction-sections \
|
||||||
-fdata-sections
|
-fdata-sections \
|
||||||
COMMON_LDFLAGS += -Wl,-gc-sections
|
-fno-exceptions \
|
||||||
|
-g
|
||||||
|
LDFLAGS += -Wl,--gc-sections
|
||||||
|
|
||||||
ifneq ($(UNUSED),)
|
ANDROID_SDK ?= ~/Android/Sdk
|
||||||
COMMON_LDFLAGS += -Wl,-print-gc-sections
|
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/33.0.1
|
||||||
|
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-33
|
||||||
|
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/23.1.7779620
|
||||||
|
ANDROID_MIN_SDK_VERSION := 28
|
||||||
|
|
||||||
|
ANDROID_ARM64_TARGETS := \
|
||||||
|
out/androiddebug/tildefriends \
|
||||||
|
out/androidrelease/tildefriends
|
||||||
|
ANDROID_X86_64_TARGETS := \
|
||||||
|
out/androiddebug-x86_64/tildefriends \
|
||||||
|
out/androidrelease-x86_64/tildefriends
|
||||||
|
ANDROID_TARGETS := \
|
||||||
|
$(ANDROID_X86_64_TARGETS) \
|
||||||
|
$(ANDROID_ARM64_TARGETS)
|
||||||
|
|
||||||
|
DEBUG_TARGETS := \
|
||||||
|
out/debug/tildefriends \
|
||||||
|
out/windebug/tildefriends \
|
||||||
|
out/androiddebug/tildefriends \
|
||||||
|
out/androiddebug-x86_64/tildefriends
|
||||||
|
RELEASE_TARGETS := \
|
||||||
|
out/release/tildefriends \
|
||||||
|
out/winrelease/tildefriends \
|
||||||
|
out/androidrelease/tildefriends \
|
||||||
|
out/androidrelease-x86_64/tildefriends
|
||||||
|
ANDROID_RELEASE_TARGETS := $(filter-out $(DEBUG_TARGETS),$(ANDROID_TARGETS))
|
||||||
|
NONANDROID_RELEASE_TARGETS := $(filter-out $(ANDROID_ARM64_TARGETS),$(RELEASE_TARGETS))
|
||||||
|
NONANDROID_TARGETS := $(filter-out $(ANDROID_TARGETS),$(DEBUG_TARGETS) $(RELEASE_TARGETS))
|
||||||
|
|
||||||
|
$(NONANDROID_TARGETS): CFLAGS += -fno-omit-frame-pointer
|
||||||
|
$(NONANDROID_TARGETS): LDFLAGS += -rdynamic
|
||||||
|
$(ANDROID_TARGETS): CFLAGS += \
|
||||||
|
--sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot \
|
||||||
|
-fPIC \
|
||||||
|
-fdebug-compilation-dir . \
|
||||||
|
-fomit-frame-pointer \
|
||||||
|
-fno-asynchronous-unwind-tables \
|
||||||
|
-funwind-tables
|
||||||
|
$(ANDROID_TARGETS): LDFLAGS += --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot -fPIC
|
||||||
|
$(DEBUG_TARGETS): CFLAGS += -DDEBUG -Og
|
||||||
|
$(RELEASE_TARGETS): CFLAGS += -DNDEBUG
|
||||||
|
$(NONANDROID_RELEASE_TARGETS): CFLAGS += -O3
|
||||||
|
$(ANDROID_RELEASE_TARGETS): CFLAGS += -Os
|
||||||
|
windebug winrelease: CC = x86_64-w64-mingw32-gcc-win32
|
||||||
|
windebug winrelease: AS = $(CC)
|
||||||
|
windebug winrelease: CFLAGS += \
|
||||||
|
-D_WIN32_WINNT=0x0A00 \
|
||||||
|
-DWINVER=0x0A00 \
|
||||||
|
-DNTDDI_VERSION=NTDDI_WIN10 \
|
||||||
|
-Ideps/openssl/mingw64/include
|
||||||
|
windebug winrelease: LDFLAGS += \
|
||||||
|
-static \
|
||||||
|
-lm \
|
||||||
|
-Ldeps/openssl/mingw64/lib
|
||||||
|
$(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android
|
||||||
|
$(ANDROID_ARM64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := aarch64-linux-android
|
||||||
|
$(ANDROID_TARGETS): CC = $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/$(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION)-clang
|
||||||
|
$(ANDROID_TARGETS): AS = $(CC)
|
||||||
|
$(ANDROID_TARGETS): CFLAGS += \
|
||||||
|
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||||
|
-Wno-unknown-warning-option
|
||||||
|
$(ANDROID_ARM64_TARGETS): CFLAGS += -Ideps/openssl/android/arm64-v8a/usr/local/include
|
||||||
|
$(ANDROID_ARM64_TARGETS): LDFLAGS += -Ldeps/openssl/android/arm64-v8a/usr/local/lib
|
||||||
|
$(ANDROID_X86_64_TARGETS): CFLAGS += -Ideps/openssl/android/x86_64/usr/local/include
|
||||||
|
$(ANDROID_X86_64_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86_64/usr/local/lib
|
||||||
|
|
||||||
|
ifeq ($(UNAME_M),x86_64)
|
||||||
|
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
|
||||||
|
debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifneq ($(DEBUG),)
|
get_objs = \
|
||||||
COMMON_CFLAGS += -g -fsanitize=address -fsanitize=undefined
|
$(foreach build_type,$(BUILD_TYPES),$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)))))) \
|
||||||
COMMON_LDFLAGS += -fsanitize=address -fsanitize=undefined
|
$(foreach build_type,debug release,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
|
||||||
BUILD_DIR := $(BUILD_DIR)/debug
|
$(foreach build_type,windebug winrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_win))))) \
|
||||||
else
|
$(foreach build_type,androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \
|
||||||
COMMON_CFLAGS += -DNDEBUG -O3
|
$(foreach build_type,androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix)))))
|
||||||
BUILD_DIR := $(BUILD_DIR)/release
|
|
||||||
endif
|
|
||||||
|
|
||||||
APP_BIN = $(BUILD_DIR)/$(PROJECT)
|
APP_SOURCES := $(wildcard src/*.c)
|
||||||
APP_SOURCES = $(wildcard src/*.c)
|
APP_OBJS := $(call get_objs,APP_SOURCES)
|
||||||
APP_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(APP_SOURCES))
|
|
||||||
$(APP_OBJS): CFLAGS += \
|
$(APP_OBJS): CFLAGS += \
|
||||||
-Ideps/base64c/include \
|
-Ideps/base64c/include \
|
||||||
-Ideps/crypt_blowfish \
|
-Ideps/crypt_blowfish \
|
||||||
|
-Ideps/libbacktrace \
|
||||||
|
-Ideps/libsodium \
|
||||||
|
-Ideps/libsodium/src/libsodium/include \
|
||||||
|
-Ideps/libuv/include \
|
||||||
|
-Ideps/zlib \
|
||||||
|
-Ideps/zlib/contrib/minizip \
|
||||||
|
-Ideps/picohttpparser \
|
||||||
-Ideps/quickjs \
|
-Ideps/quickjs \
|
||||||
-Ideps/sqlite \
|
-Ideps/sqlite \
|
||||||
-Ideps/libuv/include \
|
-Ideps/valgrind \
|
||||||
-Ideps/xopt
|
-Ideps/xopt \
|
||||||
|
-Wdouble-promotion \
|
||||||
|
-Werror
|
||||||
|
ifeq ($(UNAME_M),x86_64)
|
||||||
|
$(filter-out $(BUILD_DIR)/android%,$(APP_OBJS)): CFLAGS += \
|
||||||
|
-fanalyzer
|
||||||
|
endif
|
||||||
|
|
||||||
BASE64C_SOURCES = deps/base64c/src/base64c.c
|
BLOWFISH_SOURCES := \
|
||||||
BASE64C_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(BASE64C_SOURCES))
|
|
||||||
$(BASE64C_OBJS): CFLAGS += \
|
|
||||||
-Wno-sign-compare
|
|
||||||
|
|
||||||
BLOWFISH_SOURCES = \
|
|
||||||
deps/crypt_blowfish/crypt_blowfish.c \
|
deps/crypt_blowfish/crypt_blowfish.c \
|
||||||
deps/crypt_blowfish/crypt_gensalt.c \
|
deps/crypt_blowfish/crypt_gensalt.c \
|
||||||
deps/crypt_blowfish/wrapper.c
|
deps/crypt_blowfish/wrapper.c
|
||||||
BLOWFISH_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(BLOWFISH_SOURCES))
|
BLOWFISH_SOURCES_win = \
|
||||||
|
deps/crypt_blowfish/x86.S
|
||||||
|
BLOWFISH_OBJS := $(call get_objs,BLOWFISH_SOURCES)
|
||||||
|
|
||||||
UV_SOURCES = \
|
UV_SOURCES := \
|
||||||
deps/libuv/src/fs-poll.c \
|
deps/libuv/src/fs-poll.c \
|
||||||
deps/libuv/src/idna.c \
|
deps/libuv/src/idna.c \
|
||||||
deps/libuv/src/inet.c \
|
deps/libuv/src/inet.c \
|
||||||
deps/libuv/src/random.c \
|
deps/libuv/src/random.c \
|
||||||
deps/libuv/src/strscpy.c \
|
deps/libuv/src/strscpy.c \
|
||||||
|
deps/libuv/src/strtok.c \
|
||||||
deps/libuv/src/threadpool.c \
|
deps/libuv/src/threadpool.c \
|
||||||
deps/libuv/src/timer.c \
|
deps/libuv/src/timer.c \
|
||||||
|
deps/libuv/src/uv-common.c \
|
||||||
|
deps/libuv/src/uv-data-getter-setters.c \
|
||||||
|
deps/libuv/src/version.c
|
||||||
|
UV_SOURCES_unix := \
|
||||||
deps/libuv/src/unix/async.c \
|
deps/libuv/src/unix/async.c \
|
||||||
deps/libuv/src/unix/core.c \
|
deps/libuv/src/unix/core.c \
|
||||||
deps/libuv/src/unix/dl.c \
|
deps/libuv/src/unix/dl.c \
|
||||||
deps/libuv/src/unix/fs.c \
|
deps/libuv/src/unix/fs.c \
|
||||||
deps/libuv/src/unix/getaddrinfo.c \
|
deps/libuv/src/unix/getaddrinfo.c \
|
||||||
deps/libuv/src/unix/getnameinfo.c \
|
deps/libuv/src/unix/getnameinfo.c \
|
||||||
deps/libuv/src/unix/linux-core.c \
|
deps/libuv/src/unix/linux.c \
|
||||||
deps/libuv/src/unix/linux-inotify.c \
|
|
||||||
deps/libuv/src/unix/linux-syscalls.c \
|
|
||||||
deps/libuv/src/unix/loop-watcher.c \
|
deps/libuv/src/unix/loop-watcher.c \
|
||||||
deps/libuv/src/unix/loop.c \
|
deps/libuv/src/unix/loop.c \
|
||||||
deps/libuv/src/unix/pipe.c \
|
deps/libuv/src/unix/pipe.c \
|
||||||
@ -79,92 +169,393 @@ UV_SOURCES = \
|
|||||||
deps/libuv/src/unix/tcp.c \
|
deps/libuv/src/unix/tcp.c \
|
||||||
deps/libuv/src/unix/thread.c \
|
deps/libuv/src/unix/thread.c \
|
||||||
deps/libuv/src/unix/tty.c \
|
deps/libuv/src/unix/tty.c \
|
||||||
deps/libuv/src/unix/udp.c \
|
deps/libuv/src/unix/udp.c
|
||||||
deps/libuv/src/uv-common.c \
|
UV_SOURCES_android := \
|
||||||
deps/libuv/src/uv-data-getter-setters.c \
|
deps/libuv/src/unix/random-getentropy.c
|
||||||
deps/libuv/src/version.c
|
UV_SOURCES_win := \
|
||||||
UV_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(UV_SOURCES))
|
deps/libuv/src/win/async.c \
|
||||||
|
deps/libuv/src/win/core.c \
|
||||||
|
deps/libuv/src/win/detect-wakeup.c \
|
||||||
|
deps/libuv/src/win/dl.c \
|
||||||
|
deps/libuv/src/win/error.c \
|
||||||
|
deps/libuv/src/win/fs-event.c \
|
||||||
|
deps/libuv/src/win/fs.c \
|
||||||
|
deps/libuv/src/win/getaddrinfo.c \
|
||||||
|
deps/libuv/src/win/getnameinfo.c \
|
||||||
|
deps/libuv/src/win/handle.c \
|
||||||
|
deps/libuv/src/win/loop-watcher.c \
|
||||||
|
deps/libuv/src/win/pipe.c \
|
||||||
|
deps/libuv/src/win/poll.c \
|
||||||
|
deps/libuv/src/win/process-stdio.c \
|
||||||
|
deps/libuv/src/win/process.c \
|
||||||
|
deps/libuv/src/win/signal.c \
|
||||||
|
deps/libuv/src/win/snprintf.c \
|
||||||
|
deps/libuv/src/win/stream.c \
|
||||||
|
deps/libuv/src/win/tcp.c \
|
||||||
|
deps/libuv/src/win/thread.c \
|
||||||
|
deps/libuv/src/win/tty.c \
|
||||||
|
deps/libuv/src/win/udp.c \
|
||||||
|
deps/libuv/src/win/util.c \
|
||||||
|
deps/libuv/src/win/winapi.c \
|
||||||
|
deps/libuv/src/win/winsock.c
|
||||||
|
UV_OBJS := $(call get_objs,UV_SOURCES)
|
||||||
$(UV_OBJS): CFLAGS += \
|
$(UV_OBJS): CFLAGS += \
|
||||||
-Ideps/libuv/include \
|
-Ideps/libuv/include \
|
||||||
-Ideps/libuv/src \
|
-Ideps/libuv/src \
|
||||||
-Wno-unused-but-set-variable \
|
-Wno-dangling-pointer \
|
||||||
-Wno-incompatible-pointer-types \
|
-Wno-incompatible-pointer-types \
|
||||||
|
-Wno-maybe-uninitialized \
|
||||||
-Wno-sign-compare \
|
-Wno-sign-compare \
|
||||||
-D_GNU_SOURCE \
|
-Wno-unused-but-set-variable \
|
||||||
|
-Wno-unused-result \
|
||||||
|
-Wno-unused-variable \
|
||||||
|
-D_GNU_SOURCE
|
||||||
|
|
||||||
SQLITE_SOURCES = deps/sqlite/sqlite3.c
|
SODIUM_SOURCES := \
|
||||||
SQLITE_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(SQLITE_SOURCES))
|
deps/libsodium/src/libsodium/crypto_auth/hmacsha512/auth_hmacsha512.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_auth/hmacsha512256/auth_hmacsha512256.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_box/crypto_box.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_box/curve25519xsalsa20poly1305/box_curve25519xsalsa20poly1305.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_core/ed25519/ref10/ed25519_ref10.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_core/hsalsa20/ref2/core_hsalsa20_ref2.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_core/salsa/ref/core_salsa_ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/blake2b-compress-ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/blake2b-ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/generichash_blake2b.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_hash/sha256/cp/hash_sha256_cp.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_hash/sha256/hash_sha256.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_hash/sha512/cp/hash_sha512_cp.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_onetimeauth/poly1305/donna/poly1305_donna.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_onetimeauth/poly1305/onetimeauth_poly1305.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_pwhash/argon2/argon2-core.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_pwhash/argon2/argon2-fill-block-ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_pwhash/argon2/blake2b-long.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_scalarmult/crypto_scalarmult.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_scalarmult/curve25519/ref10/x25519_ref10.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_scalarmult/curve25519/scalarmult_curve25519.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_secretbox/crypto_secretbox_easy.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_secretbox/xsalsa20poly1305/secretbox_xsalsa20poly1305.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_sign/crypto_sign.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_sign/ed25519/ref10/keypair.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_sign/ed25519/ref10/open.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_sign/ed25519/ref10/sign.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_sign/ed25519/sign_ed25519.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_stream/chacha20/ref/chacha20_ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_stream/chacha20/stream_chacha20.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_stream/salsa20/ref/salsa20_ref.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_stream/salsa20/stream_salsa20.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_stream/xsalsa20/stream_xsalsa20.c \
|
||||||
|
deps/libsodium/src/libsodium/crypto_verify/sodium/verify.c \
|
||||||
|
deps/libsodium/src/libsodium/randombytes/randombytes.c \
|
||||||
|
deps/libsodium/src/libsodium/randombytes/sysrandom/randombytes_sysrandom.c \
|
||||||
|
deps/libsodium/src/libsodium/sodium/core.c \
|
||||||
|
deps/libsodium/src/libsodium/sodium/codecs.c \
|
||||||
|
deps/libsodium/src/libsodium/sodium/runtime.c \
|
||||||
|
deps/libsodium/src/libsodium/sodium/utils.c \
|
||||||
|
deps/libsodium/src/libsodium/sodium/version.c
|
||||||
|
SODIUM_OBJS := $(call get_objs,SODIUM_SOURCES)
|
||||||
|
$(SODIUM_OBJS): CFLAGS += \
|
||||||
|
-DCONFIGURED=1 \
|
||||||
|
-DMINIMAL=1 \
|
||||||
|
-Wno-unused-function \
|
||||||
|
-Wno-unused-variable \
|
||||||
|
-Wno-type-limits \
|
||||||
|
-Wno-unknown-pragmas \
|
||||||
|
-Ideps/libsodium/builds/msvc \
|
||||||
|
-Ideps/libsodium/src/libsodium/include/sodium
|
||||||
|
|
||||||
|
SQLITE_SOURCES := deps/sqlite/sqlite3.c
|
||||||
|
SQLITE_OBJS := $(call get_objs,SQLITE_SOURCES)
|
||||||
$(SQLITE_OBJS): CFLAGS += \
|
$(SQLITE_OBJS): CFLAGS += \
|
||||||
-DSQLITE_DBCONFIG_DEFAULT_DEFENSIVE \
|
-DSQLITE_DBCONFIG_DEFAULT_DEFENSIVE \
|
||||||
|
-DSQLITE_DEFAULT_MEMSTATUS=0 \
|
||||||
|
-DSQLITE_DQS=0 \
|
||||||
|
-DSQLITE_ENABLE_MEMSYS5 \
|
||||||
|
-DSQLITE_ENABLE_FTS5 \
|
||||||
-DSQLITE_ENABLE_JSON1 \
|
-DSQLITE_ENABLE_JSON1 \
|
||||||
-DSQLITE_MAX_LENGTH=5242880 \
|
-DSQLITE_LIKE_DOESNT_MATCH_BLOBS \
|
||||||
-DSQLITE_MAX_SQL_LENGTH=100000 \
|
-DSQLITE_MAX_ATTACHED=1 \
|
||||||
-DSQLITE_MAX_COLUMN=100 \
|
-DSQLITE_MAX_COLUMN=100 \
|
||||||
-DSQLITE_MAX_EXPR_DEPTH=20 \
|
-DSQLITE_MAX_COMPOUND_SELECT=300 \
|
||||||
-DSQLITE_MAX_COMPOUND_SELECT=3 \
|
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||||
-DSQLITE_MAX_VDBE_OP=25000 \
|
|
||||||
-DSQLITE_MAX_FUNCTION_ARG=8 \
|
-DSQLITE_MAX_FUNCTION_ARG=8 \
|
||||||
-DSQLITE_MAX_ATTACHED=0 \
|
-DSQLITE_MAX_LENGTH=5242880 \
|
||||||
-DSQLITE_MAX_LIKE_PATTERN_LENGTH=50 \
|
-DSQLITE_MAX_LIKE_PATTERN_LENGTH=50 \
|
||||||
-DSQLITE_MAX_VARIABLE_NUMBER=100 \
|
-DSQLITE_MAX_SQL_LENGTH=100000 \
|
||||||
-DSQLITE_MAX_TRIGGER_DEPTH=10 \
|
-DSQLITE_MAX_TRIGGER_DEPTH=10 \
|
||||||
|
-DSQLITE_MAX_VARIABLE_NUMBER=100 \
|
||||||
|
-DSQLITE_MAX_VDBE_OP=25000 \
|
||||||
|
-DSQLITE_OMIT_DEPRECATED \
|
||||||
|
-DSQLITE_OMIT_DESERIALIZE \
|
||||||
|
-DSQLITE_OMIT_LOAD_EXTENSION \
|
||||||
|
-DSQLITE_OMIT_TCL_VARIABLE \
|
||||||
|
-DSQLITE_PRAGMA_DEFAULT_WAL_SYNCHRONOUS=1 \
|
||||||
-DSQLITE_SECURE_DELETE \
|
-DSQLITE_SECURE_DELETE \
|
||||||
-Wno-implicit-fallthrough
|
-DSQLITE_THREADSAFE=0 \
|
||||||
|
-DSQLITE_UNTESTABLE \
|
||||||
|
-DSQLITE_USE_ALLOCA \
|
||||||
|
-DHAVE_ISNAN \
|
||||||
|
-Wno-implicit-fallthrough \
|
||||||
|
-Wno-unused-but-set-variable \
|
||||||
|
-Wno-unused-function \
|
||||||
|
-Wno-unused-variable
|
||||||
|
|
||||||
XOPT_SOURCES = deps/xopt/xopt.c
|
XOPT_SOURCES := deps/xopt/xopt.c
|
||||||
XOPT_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(XOPT_SOURCES))
|
XOPT_OBJS := $(call get_objs,XOPT_SOURCES)
|
||||||
|
$(filter $(BUILD_DIR)/win%,$(XOPT_OBJS)): CFLAGS += \
|
||||||
|
-DHAVE_SNPRINTF \
|
||||||
|
-DHAVE_VSNPRINTF \
|
||||||
|
-DHAVE_VASNPRINTF \
|
||||||
|
-DHAVE_VASPRINTF \
|
||||||
|
-Dvsnprintf=rpl_vsnprintf
|
||||||
|
$(XOPT_OBJS): CFLAGS += \
|
||||||
|
-Wno-implicit-const-int-float-conversion
|
||||||
|
|
||||||
QUICKJS_SOURCES = \
|
QUICKJS_SOURCES := \
|
||||||
deps/quickjs/cutils.c \
|
deps/quickjs/cutils.c \
|
||||||
deps/quickjs/libbf.c \
|
deps/quickjs/libbf.c \
|
||||||
deps/quickjs/libregexp.c \
|
deps/quickjs/libregexp.c \
|
||||||
deps/quickjs/libunicode.c \
|
deps/quickjs/libunicode.c \
|
||||||
deps/quickjs/quickjs-libc.c \
|
|
||||||
deps/quickjs/quickjs.c
|
deps/quickjs/quickjs.c
|
||||||
QUICKJS_OBJS = $(patsubst %.c,$(BUILD_DIR)/%.o,$(QUICKJS_SOURCES))
|
QUICKJS_OBJS := $(call get_objs,QUICKJS_SOURCES)
|
||||||
$(QUICKJS_OBJS): CFLAGS += \
|
$(QUICKJS_OBJS): CFLAGS += \
|
||||||
-DCONFIG_VERSION=\"$(shell cat deps/quickjs/VERSION)\" \
|
-DCONFIG_VERSION=\"$(shell cat deps/quickjs/VERSION)\" \
|
||||||
-DDUMP_LEAKS \
|
-DCONFIG_BIGNUM \
|
||||||
-D_GNU_SOURCE \
|
-D_GNU_SOURCE \
|
||||||
-Wno-sign-compare \
|
-Wno-enum-conversion \
|
||||||
|
-Wno-implicit-const-int-float-conversion \
|
||||||
-Wno-implicit-fallthrough \
|
-Wno-implicit-fallthrough \
|
||||||
-Wno-unused-variable \
|
-Wno-sign-compare \
|
||||||
-Wno-unused-but-set-variable
|
-Wno-unused-but-set-variable \
|
||||||
|
-Wno-unused-variable
|
||||||
|
$(NONANDROID_TARGETS): CFLAGS += -DDUMP_LEAKS
|
||||||
|
|
||||||
APP_LDFLAGS = \
|
LIBBACKTRACE_SOURCES := \
|
||||||
$(COMMON_LDFLAGS) \
|
deps/libbacktrace/atomic.c \
|
||||||
$(LDFLAGS) \
|
deps/libbacktrace/backtrace.c \
|
||||||
|
deps/libbacktrace/dwarf.c \
|
||||||
|
deps/libbacktrace/fileline.c \
|
||||||
|
deps/libbacktrace/print.c \
|
||||||
|
deps/libbacktrace/simple.c \
|
||||||
|
deps/libbacktrace/sort.c \
|
||||||
|
deps/libbacktrace/state.c
|
||||||
|
LIBBACKTRACE_SOURCES_unix := \
|
||||||
|
deps/libbacktrace/elf.c \
|
||||||
|
deps/libbacktrace/mmap.c \
|
||||||
|
deps/libbacktrace/mmapio.c \
|
||||||
|
deps/libbacktrace/posix.c
|
||||||
|
LIBBACKTRACE_SOURCES_win := \
|
||||||
|
deps/libbacktrace/alloc.c \
|
||||||
|
deps/libbacktrace/pecoff.c \
|
||||||
|
deps/libbacktrace/posix.c \
|
||||||
|
deps/libbacktrace/read.c
|
||||||
|
LIBBACKTRACE_OBJS := $(call get_objs,LIBBACKTRACE_SOURCES)
|
||||||
|
$(LIBBACKTRACE_OBJS): CFLAGS += \
|
||||||
|
-Ideps/libbacktrace_config \
|
||||||
|
-Wno-unused-but-set-variable \
|
||||||
|
-Wno-maybe-initialized \
|
||||||
|
-Wno-unused-function \
|
||||||
|
-DBACKTRACE_ELF_SIZE=64
|
||||||
|
|
||||||
|
PICOHTTPPARSER_SOURCES := \
|
||||||
|
deps/picohttpparser/picohttpparser.c
|
||||||
|
PICOHTTPPARSER_OBJS := $(call get_objs,PICOHTTPPARSER_SOURCES)
|
||||||
|
|
||||||
|
MINIUNZIP_SOURCES := \
|
||||||
|
deps/zlib/contrib/minizip/unzip.c \
|
||||||
|
deps/zlib/contrib/minizip/ioapi.c \
|
||||||
|
deps/zlib/adler32.c \
|
||||||
|
deps/zlib/crc32.c \
|
||||||
|
deps/zlib/inffast.c \
|
||||||
|
deps/zlib/inflate.c \
|
||||||
|
deps/zlib/inftrees.c \
|
||||||
|
deps/zlib/zutil.c
|
||||||
|
MINIUNZIP_OBJS := $(call get_objs,MINIUNZIP_SOURCES)
|
||||||
|
$(MINIUNZIP_OBJS): CFLAGS += \
|
||||||
|
-Ideps/zlib \
|
||||||
|
-Wno-maybe-uninitialized
|
||||||
|
|
||||||
|
LDFLAGS += \
|
||||||
-pthread \
|
-pthread \
|
||||||
|
-lm
|
||||||
|
debug release: LDFLAGS += \
|
||||||
-ldl \
|
-ldl \
|
||||||
-lm \
|
-lssl \
|
||||||
|
-lcrypto
|
||||||
|
windebug winrelease: LDFLAGS += \
|
||||||
-lssl \
|
-lssl \
|
||||||
-lcrypto \
|
-lcrypto \
|
||||||
-lsodium
|
-lcrypt32 \
|
||||||
|
-ldbghelp \
|
||||||
|
-liphlpapi \
|
||||||
|
-lkernel32 \
|
||||||
|
-lole32 \
|
||||||
|
-luserenv \
|
||||||
|
-luuid \
|
||||||
|
-lws2_32 \
|
||||||
|
-lwsock32
|
||||||
|
$(ANDROID_TARGETS): LDFLAGS += \
|
||||||
|
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||||
|
-ldl \
|
||||||
|
-llog \
|
||||||
|
-lssl \
|
||||||
|
-lcrypto
|
||||||
|
|
||||||
DEFAULT_TARGET = $(APP_BIN)
|
unix: debug release
|
||||||
all: $(DEFAULT_TARGET)
|
win: windebug winrelease
|
||||||
.PHONY: all
|
all: $(BUILD_TYPES) out/TildeFriends-debug.apk out/TildeFriends-release.apk
|
||||||
|
.PHONY: all win unix
|
||||||
|
|
||||||
ALL_APP_OBJS = \
|
ALL_APP_OBJS := \
|
||||||
$(APP_OBJS) \
|
$(APP_OBJS) \
|
||||||
$(BASE64C_OBJS) \
|
|
||||||
$(BLOWFISH_OBJS) \
|
$(BLOWFISH_OBJS) \
|
||||||
$(UV_OBJS) \
|
$(LIBBACKTRACE_OBJS) \
|
||||||
$(SQLITE_OBJS) \
|
$(MINIUNZIP_OBJS) \
|
||||||
|
$(PICOHTTPPARSER_OBJS) \
|
||||||
$(QUICKJS_OBJS) \
|
$(QUICKJS_OBJS) \
|
||||||
|
$(SODIUM_OBJS) \
|
||||||
|
$(SQLITE_OBJS) \
|
||||||
|
$(UV_OBJS) \
|
||||||
$(XOPT_OBJS)
|
$(XOPT_OBJS)
|
||||||
|
|
||||||
DEPS = $(ALL_APP_OBJS:.o=.d)
|
DEPS = $(ALL_APP_OBJS:.o=.d)
|
||||||
-include $(DEPS)
|
-include $(DEPS)
|
||||||
|
|
||||||
$(APP_BIN): $(ALL_APP_OBJS)
|
define build_rules
|
||||||
$(CC) -o $@ $^ $(APP_LDFLAGS)
|
$(1): $(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe)
|
||||||
|
.PHONY: $(1)
|
||||||
|
|
||||||
$(BUILD_DIR)/%.o: %.c
|
$(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe): $(filter $(BUILD_DIR)/$(1)/%,$(ALL_APP_OBJS))
|
||||||
|
@echo [link] $$@
|
||||||
|
@$$(CC) -o $$@ -Wl,-Map,$$@.map $$^ $$(LDFLAGS)
|
||||||
|
|
||||||
|
$(BUILD_DIR)/$(1)/%.o: %.c
|
||||||
|
@mkdir -p $$(dir $$@)
|
||||||
|
@echo [c] $$@
|
||||||
|
@$$(CC) $$(CFLAGS) -c $$< -o $$@
|
||||||
|
|
||||||
|
$(BUILD_DIR)/$(1)/%.o: %.S
|
||||||
|
@mkdir -p $$(dir $$@)
|
||||||
|
@echo [as] $$@
|
||||||
|
@$$(AS) -c $$< -o $$@
|
||||||
|
endef
|
||||||
|
|
||||||
|
$(foreach build_type,$(BUILD_TYPES),$(eval $(call build_rules,$(build_type))))
|
||||||
|
|
||||||
|
src/version.h : $(firstword $(MAKEFILE_LIST))
|
||||||
|
@echo [version] $@
|
||||||
|
@echo "#define VERSION_NUMBER \"$(VERSION_NUMBER)\"\n#define VERSION_NAME \"$(VERSION_NAME)\"\n" > $@
|
||||||
|
|
||||||
|
src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST))
|
||||||
|
@echo [android_version] $@
|
||||||
|
@sed -i \
|
||||||
|
-e 's/versionCode=".*"/versionCode="$(VERSION_CODE)"/' \
|
||||||
|
-e 's/versionName=".*"/versionName="$(VERSION_NUMBER)"/' \
|
||||||
|
-e 's/android:minSdkVersion=".*"/android:minSdkVersion="$(ANDROID_MIN_SDK_VERSION)"/' \
|
||||||
|
$@
|
||||||
|
|
||||||
|
# Android support.
|
||||||
|
out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml
|
||||||
@mkdir -p $(dir $@)
|
@mkdir -p $(dir $@)
|
||||||
@echo [c] $@
|
@echo [aapt2] $@
|
||||||
@$(CC) $(COMMON_CFLAGS) $(CFLAGS) -c $< -o $@
|
@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/layout/activity_main.xml
|
||||||
|
|
||||||
|
out/res/drawable_icon.xml.flat: src/android/res/drawable/icon.xml
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
@echo [aapt2] $@
|
||||||
|
@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/drawable/icon.xml
|
||||||
|
|
||||||
|
out/apk/res.apk out/gen/com/unprompted/tildefriends/R.java: out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat src/android/AndroidManifest.xml
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
@$(ANDROID_BUILD_TOOLS)/aapt2 link -I $(ANDROID_PLATFORM)/android.jar out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat --manifest src/android/AndroidManifest.xml -o out/apk/res.apk --java out/gen/
|
||||||
|
|
||||||
|
JAVA_FILES := out/gen/com/unprompted/tildefriends/R.java $(wildcard src/android/com/unprompted/tildefriends/*.java)
|
||||||
|
CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class)))
|
||||||
|
|
||||||
|
$(CLASS_FILES) &: $(JAVA_FILES)
|
||||||
|
@echo [javac] $(CLASS_FILES)
|
||||||
|
@javac --release 8 -Xlint:deprecation -classpath $(ANDROID_PLATFORM)/android.jar -d out/classes $(JAVA_FILES)
|
||||||
|
|
||||||
|
out/apk/classes.dex: $(CLASS_FILES)
|
||||||
|
@mkdir -p $(dir $@)
|
||||||
|
@echo [d8] $@
|
||||||
|
@$(ANDROID_BUILD_TOOLS)/d8 --$(BUILD_TYPE) --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class
|
||||||
|
|
||||||
|
PACKAGE_DIRS := \
|
||||||
|
apps/ \
|
||||||
|
core/ \
|
||||||
|
deps/codemirror/ \
|
||||||
|
deps/lit/
|
||||||
|
|
||||||
|
RAW_FILES := $(shell find $(PACKAGE_DIRS) -type f)
|
||||||
|
|
||||||
|
out/apk/TildeFriends-debug.unsigned.apk: BUILD_TYPE := debug
|
||||||
|
out/apk/TildeFriends-release.unsigned.apk: BUILD_TYPE := release
|
||||||
|
|
||||||
|
out/apk/TildeFriends-debug.unsigned.apk: out/apk/classes.dex out/androiddebug/tildefriends out/androiddebug-x86_64/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||||
|
out/apk/TildeFriends-release.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-x86_64/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||||
|
|
||||||
|
out/%.unsigned.apk:
|
||||||
|
@mkdir -p $(dir $@) out/apk$(BUILD_TYPE)/bin/aarch64/ out/apk$(BUILD_TYPE)/bin/x86_64/
|
||||||
|
@echo [aapt] $@
|
||||||
|
@cp out/android$(BUILD_TYPE)/tildefriends out/apk$(BUILD_TYPE)/bin/aarch64/
|
||||||
|
@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk$(BUILD_TYPE)/bin/x86_64/
|
||||||
|
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk$(BUILD_TYPE)/bin/aarch64/tildefriends
|
||||||
|
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk$(BUILD_TYPE)/bin/x86_64/tildefriends
|
||||||
|
@cp out/apk/res.apk $@
|
||||||
|
@cp out/apk/classes.dex out/apk$(BUILD_TYPE)/
|
||||||
|
@cd out/apk$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
|
||||||
|
@zip -u $@ -q -9 -x '*.map' -r $(PACKAGE_DIRS) $(RAW_FILES)
|
||||||
|
|
||||||
|
out/%.apk: out/apk/%.unsigned.apk
|
||||||
|
@echo [apksigner] $(notdir $@)
|
||||||
|
@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks keystore.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --out $@ $<
|
||||||
|
|
||||||
|
apk: out/TildeFriends-release.apk
|
||||||
|
.PHONY: apk
|
||||||
|
|
||||||
|
apkgo: out/TildeFriends-release.apk
|
||||||
|
@adb install $<
|
||||||
|
@adb shell am start com.unprompted.tildefriends/.MainActivity
|
||||||
|
.PHONY: apkgo
|
||||||
|
|
||||||
|
apklog:
|
||||||
|
@adb logcat *:S tildefriends
|
||||||
|
.PHONY: apklog
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
|
||||||
|
dist: apk
|
||||||
|
@echo "[export] $$(svn info --show-item url)"
|
||||||
|
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||||
|
@svn export -q . tildefriends-$(VERSION_NUMBER)
|
||||||
|
@echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION
|
||||||
|
@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz"
|
||||||
|
@tar \
|
||||||
|
--exclude=apps/gg* \
|
||||||
|
--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
|
||||||
|
--exclude=deps/libsodium/builds/msvc/vs* \
|
||||||
|
--exclude=deps/libsodium/builds/msvc/build \
|
||||||
|
--exclude=deps/libsodium/builds/msvc/properties \
|
||||||
|
--exclude=deps/libsodium/configure \
|
||||||
|
--exclude=deps/libsodium/test \
|
||||||
|
--exclude=deps/libuv/docs \
|
||||||
|
--exclude=deps/libuv/test \
|
||||||
|
--exclude=deps/openssl \
|
||||||
|
--exclude=deps/speedscope/*.map \
|
||||||
|
--exclude=deps/sqlite/shell.c \
|
||||||
|
--exclude=deps/zlib/contrib/vstudio \
|
||||||
|
--exclude=deps/zlib/doc \
|
||||||
|
-caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER)
|
||||||
|
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||||
|
@echo "[cp] TildeFriends-$(VERSION_NUMBER).apk"
|
||||||
|
@cp out/TildeFriends-release.apk TildeFriends-$(VERSION_NUMBER).apk
|
||||||
|
.PHONY: dist
|
||||||
|
|
||||||
|
dist-test: dist
|
||||||
|
@tar -xf tildefriends-$(VERSION_NUMBER).tar.xz
|
||||||
|
@$(MAKE) -C tildefriends-$(VERSION_NUMBER)/ debug release
|
||||||
|
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||||
|
.PHONY: dist-test
|
||||||
|
36
README.md
36
README.md
@ -1,26 +1,36 @@
|
|||||||
# Tilde Friends
|
# Tilde Friends
|
||||||
Tilde Friends is a program that aims to securely host and share pure JavaScript web applications.
|
Tilde Friends is a tool for making and sharing.
|
||||||
|
|
||||||
|
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
|
## Goals
|
||||||
1. Make it easy and fun to run all sorts of web applications.
|
1. Make it easy and fun to run all sorts of web applications.
|
||||||
2. Provide a security model that is easy to understand and protects your data.
|
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.
|
3. Make creating and sharing web applications accessible to anyone with a
|
||||||
|
browser.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
1. Requires libsodium and openssl. Other dependencies are kept up to date in the tree.
|
1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies
|
||||||
2. To build, run `make` or `make DEBUG=1`. An executable will be generated in a subdirectory of `out/`.
|
are kept up to date in the tree.
|
||||||
|
2. To build, run `make debug` or `make release`. An executable will be
|
||||||
|
generated in a subdirectory of `out/`.
|
||||||
|
3. `make windebug` or `make winrelease` will generate a windows executable
|
||||||
|
which might work.
|
||||||
|
4. To build in docker, `docker build .`.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
This is only just starting to show some signs of beginning to work as intended. Set expectations low.
|
By default, running the built `tildefriends` executable will start a web server
|
||||||
|
at <http://localhost:12345/>. `tildefriends -h` lists further options.
|
||||||
|
|
||||||
Running the built `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
|
||||||
The first user to create an account and log in will be granted administrative privileges. Everything can be managed entirely from the web interface.
|
<http://localhost:12345/~core/admin/`>.
|
||||||
|
|
||||||
Some starter apps can be installed by running `tildefriends import -u cory`. Hint: `~cory/docs/` and `~cory/index/`.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
There are the very beginnings of developer documentation in `apps/cory/docs/` that can be read in-place or in-browser by running `tildefriends import -u cory` and then visiting <http://localhost:12345/~cory/docs/>.
|
There are the very beginnings of developer documentation in `apps/docs/`
|
||||||
|
that can be read in-place or at <http://localhost:12345/~core/docs/>.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
All code unless otherwise noted in [COPYING](https://www.unprompted.com/projects/browser/projects/tildefriends/trunk/COPYING) is provided under the [Affero GPL 3.0](https://www.unprompted.com/projects/browser/projects/tildefriends/trunk/LICENSE) license.
|
All code unless otherwise noted in is provided under the
|
||||||
|
[MIT](https://opensource.org/licenses/MIT) license.
|
||||||
|
4
apps/admin.json
Normal file
4
apps/admin.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "🎛"
|
||||||
|
}
|
26
apps/admin/app.js
Normal file
26
apps/admin/app.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
|
||||||
|
tfrpc.register(function delete_user(user) {
|
||||||
|
return core.deleteUser(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
tfrpc.register(function global_settings_set(key, value) {
|
||||||
|
return core.globalSettingsSet(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
let data = {
|
||||||
|
users: {},
|
||||||
|
granted: await core.allPermissionsGranted(),
|
||||||
|
settings: await core.globalSettingsDescriptions(),
|
||||||
|
};
|
||||||
|
for (let user of await core.users()) {
|
||||||
|
data.users[user] = await core.permissionsForUser(user);
|
||||||
|
}
|
||||||
|
await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
|
||||||
|
} catch {
|
||||||
|
await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main();
|
10
apps/admin/index.html
Normal file
10
apps/admin/index.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="width: 100%">
|
||||||
|
<head>
|
||||||
|
<script>const g_data = $data;</script>
|
||||||
|
</head>
|
||||||
|
<body style="color: #fff; width: 100%">
|
||||||
|
<h1>Tilde Friends Administration</h1>
|
||||||
|
</body>
|
||||||
|
<script type="module" src="script.js"></script>
|
||||||
|
</html>
|
13
apps/admin/lit.min.js
vendored
Normal file
13
apps/admin/lit.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
87
apps/admin/script.js
Normal file
87
apps/admin/script.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {html, render} from './lit.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
|
||||||
|
function delete_user(user) {
|
||||||
|
if (confirm(`Are you sure you want to delete the user "${user}"?`)) {
|
||||||
|
tfrpc.rpc.delete_user(user).then(function() {
|
||||||
|
alert(`User "${user}" deleted successfully.`);
|
||||||
|
}).catch(function(error) {
|
||||||
|
alert(`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function global_settings_set(key, value) {
|
||||||
|
tfrpc.rpc.global_settings_set(key, value).then(function() {
|
||||||
|
alert(`Set "${key}" to "${value}".`);
|
||||||
|
}).catch(function(error) {
|
||||||
|
alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', function() {
|
||||||
|
const permission_template = (permission) =>
|
||||||
|
html` <code>${permission}</code>`;
|
||||||
|
function input_template(key, description) {
|
||||||
|
if (description.type === 'boolean') {
|
||||||
|
return html`
|
||||||
|
<div style="margin-top: 1em">
|
||||||
|
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
|
||||||
|
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button>
|
||||||
|
<div>${description.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (description.type === 'textarea') {
|
||||||
|
return html`
|
||||||
|
<div style="margin-top: 1em"">
|
||||||
|
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||||
|
<div style="width: 100%; padding: 0; margin: 0">
|
||||||
|
<div style="width: 90%; padding: 0 margin: 0">
|
||||||
|
<textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea>
|
||||||
|
</div>
|
||||||
|
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstElementChild.value)}>Set</button>
|
||||||
|
<div>${description.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return html`
|
||||||
|
<div style="margin-top: 1em">
|
||||||
|
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||||
|
<div>
|
||||||
|
<input type="text" value="${description.value}" id=${'gs_' + key}></input>
|
||||||
|
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button>
|
||||||
|
<div>${description.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const user_template = (user, permissions) => html`
|
||||||
|
<li>
|
||||||
|
<button @click=${(e) => delete_user(user)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
${user}:
|
||||||
|
${permissions.map(x => permission_template(x))}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
const users_template = (users) =>
|
||||||
|
html`<h2>Users</h2>
|
||||||
|
<ul>
|
||||||
|
${Object.entries(users).map(u => user_template(u[0], u[1]))}
|
||||||
|
</ul>`;
|
||||||
|
const page_template = (data) =>
|
||||||
|
html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
|
||||||
|
<h2>Global Settings</h2>
|
||||||
|
<div>
|
||||||
|
${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
|
||||||
|
</div>
|
||||||
|
${users_template(data.users)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
render(page_template(g_data), document.body);
|
||||||
|
});
|
4
apps/api.json
Normal file
4
apps/api.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "📜"
|
||||||
|
}
|
77
apps/api/app.js
Normal file
77
apps/api/app.js
Normal file
File diff suppressed because one or more lines are too long
313
apps/api/docs.js
Normal file
313
apps/api/docs.js
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
export const docs = {};
|
||||||
|
|
||||||
|
docs.global = `# Tilde Friends API Documentation
|
||||||
|
|
||||||
|
Welcome to the Tilde Friends API documentation.
|
||||||
|
|
||||||
|
* [App Globals](#App_Globals)
|
||||||
|
* [Database Interface](#Database)
|
||||||
|
* [Remote Procedure Calls](#tfrpc)
|
||||||
|
|
||||||
|
<a id="App_Globals"></a>
|
||||||
|
## <span style="color: #aaf">App Globals</span>
|
||||||
|
The following are functions and values exposed to all apps in their \`app.js\` or \`handler.js\`. Most
|
||||||
|
of these are asynchronous, returning a \`Promise\` that will be resolved when the call completes, unless
|
||||||
|
noted otherwise.
|
||||||
|
|
||||||
|
This is all a work in progess. These are liable to change without warning. Feedback is welcome.
|
||||||
|
|
||||||
|
The exposed functions in this API balance multiple competing needs:
|
||||||
|
* The surface area of the exposed API ought to be fairly minimal. If something can be implemented entirely app-side, that is
|
||||||
|
generally preferred over building it into the core.
|
||||||
|
* Everything is built on this API. Ideally the admin app, the SSB app, and the editor all use standard API exposed to all
|
||||||
|
apps, with appropriate permission guards in place making it so that only trusted apps do potentially destructive operations.
|
||||||
|
There will be some things here that aren't necessarily general use to support what's required.
|
||||||
|
|
||||||
|
If you are looking at the [Tilde Friends source code](https://www.tildefriends.net/~cory/releases/),
|
||||||
|
the vast majority of these are implemented in \`src/*.js.c\` files, and exposed to apps via \`core/core.js\`.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['core.user.credentials.session.name'] = `
|
||||||
|
*String* The name of the authenticated user.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['app.setDocument()'] = `
|
||||||
|
Set the contents of the client <iframe/>.
|
||||||
|
### Parameters
|
||||||
|
* *String* **html** The HTML contents.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['ssb.sqlAsync()'] = `
|
||||||
|
Run an SQL query against the sqlite database.
|
||||||
|
### Parameters
|
||||||
|
* *String* **query** The sqlite query.
|
||||||
|
* *Array* **args** The query arguments to bind.
|
||||||
|
* *Function* **callback** Callback called for each row result.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['ssb.appendMessageWithIdentity()'] = `
|
||||||
|
Signs and stores a message in the SSB database.
|
||||||
|
### Parameters
|
||||||
|
* *String* **id** The public key of an SSB identity owned by the authenticated user.
|
||||||
|
* *Object* **message** The unsigned message.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['ssb.storeMessage()'] = `
|
||||||
|
Verifies and stores a signed message in the SSB database.
|
||||||
|
### Parameters
|
||||||
|
* *Object* **message** The valid, signed message to store.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['ssb.blobStore()'] = `
|
||||||
|
Store a blob in the SSB database.
|
||||||
|
### Parameters
|
||||||
|
* *String*/*Uint8Array* **blob** The blob contents to store
|
||||||
|
### Returns
|
||||||
|
*String* The stored blob ID.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['ssb.blobGet()'] = `
|
||||||
|
Fetches a blob from the database.
|
||||||
|
### Parameters
|
||||||
|
* *String* **blob_id** The blob identifier to fetch (\`&....sha256\`).
|
||||||
|
### Returns
|
||||||
|
*ArrayBuffer* The blob data.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['print()'] = `
|
||||||
|
Log debug information both to the server's console and to the visiting user's browser console when possible.
|
||||||
|
### Parameters
|
||||||
|
* **...** Whatever you want to log. Will be joined with spaces.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database()'] = `
|
||||||
|
Returns a database instance that is specific to the authenticated user and the given key.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The database key.
|
||||||
|
### Returns
|
||||||
|
*Database* A database.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['my_shared_database()'] = `
|
||||||
|
Returns a database instance that is specific to the authenticated user and the given key.
|
||||||
|
### Parameters
|
||||||
|
* *String* **package_name** The database package name.
|
||||||
|
* *String* **key** The database key.
|
||||||
|
### Returns
|
||||||
|
*Database* A database.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['shared_database()'] = `
|
||||||
|
Returns a database instance that is shared between all users of the app, determined by its owner and app name.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The database key.
|
||||||
|
### Returns
|
||||||
|
*Database* A database.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['base64Decode()'] = `
|
||||||
|
Decode a base64 string to bytes.
|
||||||
|
|
||||||
|
Completes synchronously.
|
||||||
|
### Parameters
|
||||||
|
* *String* value The base64-encoded string.
|
||||||
|
### Returns
|
||||||
|
*Uint8Array* The decoded bytes.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['base64Encode()'] = `
|
||||||
|
Encode bytes to a base64 string.
|
||||||
|
|
||||||
|
Completes synchronously.
|
||||||
|
### Parameters
|
||||||
|
* *Uint8Array* The bytes to encode.
|
||||||
|
### Returns
|
||||||
|
*String* The base64-encoded string.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['utf8Decode()'] = `
|
||||||
|
Decode UTF-8 bytes to a string.
|
||||||
|
|
||||||
|
Completes synchronously.
|
||||||
|
### Parameters
|
||||||
|
* *Uint8Array* **value** The value to decode.
|
||||||
|
### Returns
|
||||||
|
*String* The value as a string.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['utf8Encode()'] = `
|
||||||
|
Encodes a string to UTF-8 bytes.
|
||||||
|
|
||||||
|
Completes synchronously.
|
||||||
|
### Parameters
|
||||||
|
* *String* **value** The value to encode.
|
||||||
|
### Returns
|
||||||
|
*Uint8Array* The encoded \`value\`.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['setTimeout()'] = `
|
||||||
|
Call a function after some delay.
|
||||||
|
### Parameters
|
||||||
|
* *Function* **callback** The function to call.
|
||||||
|
* *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?
|
||||||
|
|
||||||
|
Completes synchronously.
|
||||||
|
### Parameters
|
||||||
|
* *Integer* **exit_code** System exit code.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['version()'] = `
|
||||||
|
Gets version information for the running server.
|
||||||
|
### Returns
|
||||||
|
*Object* Keys are things like \`name\` and \`number\` for the server itself and \`libuv\` and \`openssl\` for
|
||||||
|
dependencies. Values are *String* version numbers.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['platform()'] = `
|
||||||
|
Gets the host operating system platform of the running server.
|
||||||
|
### Returns
|
||||||
|
*String* The platform, one of \`windows\`, \`android\`, \`linux\`, or \`other\`.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['getFile()'] = `
|
||||||
|
Gets a file from the running app.
|
||||||
|
### Parameters
|
||||||
|
* *String* **name** Name of the file to retrieve.
|
||||||
|
### Returns
|
||||||
|
*Uint8Array* The contents of a file from the app with the given name, or *undefined*.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs.database = `
|
||||||
|
# <span style="color: #aaf">Database</span>
|
||||||
|
Local-only storage is provided by a \`Database\` type representing a key-value store.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.get()'] = `
|
||||||
|
Gets a value from the database.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The key.
|
||||||
|
### Returns
|
||||||
|
*String* The value from the database or undefined if not found.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.getAll()'] = `
|
||||||
|
Gets all keys from the database.
|
||||||
|
### Returns
|
||||||
|
*Array* An array of *String* key names for all keys in the given database.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.getLike()'] = `
|
||||||
|
Gets all keys and values from the database matching a pattern.
|
||||||
|
### Parameters
|
||||||
|
* *String* **pattern** An sqlite \`LIKE\` pattern to match keys against.
|
||||||
|
### Returns
|
||||||
|
*Object* An object whose keys are the database keys and values are the database values that match the given pattern.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.set()'] = `
|
||||||
|
Sets a value in the database, creating a new entry or replacing an existing entry.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The key.
|
||||||
|
* *String* **value** The value.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.exchange()'] = `
|
||||||
|
Performs an atomic compare and exchange operation, setting a value in the database only if its current value matches what is expected.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The key.
|
||||||
|
* *String* **expected** The expected value.
|
||||||
|
* *String* **value** The new value.
|
||||||
|
### Returns
|
||||||
|
*Boolean* true if the value is now the given value.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['database.remove()'] = `
|
||||||
|
Removes an entry from the database if it exists.
|
||||||
|
### Parameters
|
||||||
|
* *String* **key** The key.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs.tfrpc = `
|
||||||
|
# <span style="color: #aaf" id="tfrpc">tfrpc</span>
|
||||||
|
\`tfrpc.js\` is a small helper script that is available to be used to facilitate communication between parts of an application.
|
||||||
|
|
||||||
|
\`tfrpc.js\` can be used to asynchronously make calls between the app code running in a sandboxed iframe in the browser
|
||||||
|
and the app process on the server.
|
||||||
|
|
||||||
|
From \`app.js\`:
|
||||||
|
\`\`\`
|
||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
|
||||||
|
From script running in the browser:
|
||||||
|
\`\`\`
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Either side can register or call functions, though they must be registered before they can be called. Arguments and return
|
||||||
|
values are ultimately serialized by means that attempt to preserve most JSON-serializable values as well as functions themselves.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['tfrpc.register()'] = `
|
||||||
|
Register a function, allowing it to be called remotely.
|
||||||
|
### Parameters
|
||||||
|
* *Function* **function** The function to register. Its name will be how it will be called.
|
||||||
|
`;
|
||||||
|
|
||||||
|
docs['tfrpc.rpc.*()'] = `
|
||||||
|
Call a remote function.
|
||||||
|
### Parameters
|
||||||
|
* **...** Parameters to pass to the function.
|
||||||
|
### Returns
|
||||||
|
The return value of the called function.
|
||||||
|
`;
|
4
apps/apps.json
Normal file
4
apps/apps.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "💻"
|
||||||
|
}
|
77
apps/apps/app.js
Normal file
77
apps/apps/app.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
async function fetch_info(apps) {
|
||||||
|
let result = {};
|
||||||
|
for (let [key, value] of Object.entries(apps)) {
|
||||||
|
let blob = await ssb.blobGet(value);
|
||||||
|
blob = blob ? utf8Decode(blob) : '{}';
|
||||||
|
result[key] = JSON.parse(blob);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
var apps = await fetch_info(await core.apps());
|
||||||
|
var core_apps = await fetch_info(await core.apps('core'));
|
||||||
|
var doc = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, 64px);
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
height: 96px;
|
||||||
|
width: 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.app > a {
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 64px;
|
||||||
|
text-overflow: ellipsis ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="background: #888">
|
||||||
|
<h1 id="apps_title">Apps</h1>
|
||||||
|
<div id="apps" class="container"></div>
|
||||||
|
<h1>Core Apps</h1>
|
||||||
|
<div id="core_apps" class="container"></div>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
function populate_apps(id, name, apps) {
|
||||||
|
var list = document.getElementById(id);
|
||||||
|
for (let app of Object.keys(apps).sort()) {
|
||||||
|
let div = list.appendChild(document.createElement('div'));
|
||||||
|
div.classList.add('app');
|
||||||
|
|
||||||
|
let icon_a = document.createElement('a');
|
||||||
|
let icon = document.createElement('div');
|
||||||
|
icon.appendChild(document.createTextNode(apps[app].emoji || '📦'));
|
||||||
|
icon.style.fontSize = 'xxx-large';
|
||||||
|
icon_a.appendChild(icon);
|
||||||
|
icon_a.href = '/~' + name + '/' + app + '/';
|
||||||
|
icon_a.target = '_top';
|
||||||
|
div.appendChild(icon_a);
|
||||||
|
|
||||||
|
let a = document.createElement('a');
|
||||||
|
a.appendChild(document.createTextNode(app));
|
||||||
|
a.href = '/~' + name + '/' + app + '/';
|
||||||
|
a.target = '_top';
|
||||||
|
div.appendChild(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('apps_title').innerText = "~${escape(core.user.credentials?.session?.name || 'guest')}'s Apps";
|
||||||
|
populate_apps('apps', '${core.user.credentials?.session?.name}', ${JSON.stringify(apps)});
|
||||||
|
populate_apps('core_apps', 'core', ${JSON.stringify(core_apps)});
|
||||||
|
</script>
|
||||||
|
</html>`;
|
||||||
|
app.setDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
4
apps/appstore.json
Normal file
4
apps/appstore.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "🛍"
|
||||||
|
}
|
55
apps/appstore/app.js
Normal file
55
apps/appstore/app.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
async function get_apps() {
|
||||||
|
let results = {};
|
||||||
|
await ssb.sqlAsync(`
|
||||||
|
SELECT messages.*
|
||||||
|
FROM messages_fts('"application/tildefriends"')
|
||||||
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||||
|
ORDER BY timestamp
|
||||||
|
`,
|
||||||
|
[],
|
||||||
|
function(row) {
|
||||||
|
let content = JSON.parse(row.content);
|
||||||
|
for (let mention of content.mentions) {
|
||||||
|
if (mention?.type === 'application/tildefriends') {
|
||||||
|
results[JSON.stringify([row.author, mention.name])] = {
|
||||||
|
message: row,
|
||||||
|
blob: mention.link,
|
||||||
|
name: mention.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Object.values(results).sort((x, y) => y.message.timestamp - x.message.timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render_app(app) {
|
||||||
|
return `
|
||||||
|
<div style="border: 2px solid white; display: inline-block; margin: 8px; padding: 8px">
|
||||||
|
<a href="/~cory/ssb/#${app.message.author}">@</a>
|
||||||
|
<a href="/~cory/ssb/#${app.message.id}">%</a>
|
||||||
|
<a href="/${app.blob}/">${app.name}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
let apps = await get_apps();
|
||||||
|
app.setDocument(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<base target="_top">
|
||||||
|
<style>
|
||||||
|
a:link { color: #bbf; }
|
||||||
|
a:visited { color: #ddd; }
|
||||||
|
a:hover { color: #ddf; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="color: #fff">
|
||||||
|
<h1>${apps.length} apps</h1>
|
||||||
|
${apps.map(render_app).join('\n')}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
@ -1 +0,0 @@
|
|||||||
{"type":"tildefriends-app","files":{"app.js":"&WCq6ssQedT5denXPXlz2BswPD6hmt++EmWIMIDUMurA=.sha256","index.md":"&Lr7IXs8osbmWz6SDsGTQCiybbxkbWSK2MrUcXMzgqTs=.sha256","todo.md":"&XrOJ3D5YMTN+j+0hJgLLy7Y61B6Z14ebv+60ee+N37I=.sha256","structure.md":"&xRhQ4Mpom1Idskum07osbBQYcYWroH0sELQBkQHrOMg=.sha256","purpose.md":"&c0/YqFhXC0X3DqiEo55NqzI5wq0VTw6cVZTf/gAWS3w=.sha256","guide.md":"&SgnGL0+rjetY2o9A2+lVRbNvHIkqKwMnZr9gXWneIlc=.sha256"}}
|
|
File diff suppressed because one or more lines are too long
@ -1,11 +0,0 @@
|
|||||||
# Tilde Friends Documentation
|
|
||||||
|
|
||||||
Tilde Friends is a participating member of a greater social
|
|
||||||
network, [Secure Scuttlebutt](https://scuttlebutt.nz/),
|
|
||||||
augmenting it with a way to safely and securely write, share,
|
|
||||||
and run code.
|
|
||||||
|
|
||||||
- [Purpose](#purpose)
|
|
||||||
- [Structure](#structure)
|
|
||||||
- [Guide](#guide)
|
|
||||||
- [TODO](#todo)
|
|
4
apps/cory/docs/markdeep.min.js
vendored
4
apps/cory/docs/markdeep.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,24 +0,0 @@
|
|||||||
# Tilde Friends Purpose
|
|
||||||
[Back to index](#index)
|
|
||||||
|
|
||||||
## Beliefs
|
|
||||||
1. The web is the universal virtual machine.
|
|
||||||
- It is here, ready to be used from your desktop, laptop, smart phone,
|
|
||||||
tablet, game console, and smart TV.
|
|
||||||
- It is not ideal, but it is the best we have right now,
|
|
||||||
and all signs point to it continuing to improve, at least
|
|
||||||
in terms of features, security, and device support.
|
|
||||||
2. Distributed is superior to centralized.
|
|
||||||
- Distributed services don't need ads.
|
|
||||||
- Distributed services can't be acquired by evil corporations.
|
|
||||||
- Distributed services respect the user's privacy.
|
|
||||||
- Distributed services respect the user.
|
|
||||||
3. Offline-first is superior to online-only.
|
|
||||||
- The internet goes down sometimes. Applications should continue
|
|
||||||
to work.
|
|
||||||
3. Making and sharing code should be easy.
|
|
||||||
- Cloning your repository, installing dev tools, running a
|
|
||||||
docker image, or fighting with dependencies is *not* easy.
|
|
||||||
- If you see a thing in a web browser, you should be able to click
|
|
||||||
`edit`, make a change, save, and see the result.
|
|
||||||
[Wikipedia](https://www.wikipedia.org/) is easy.
|
|
@ -1,48 +0,0 @@
|
|||||||
# Tilde Friends TODO
|
|
||||||
[Back to index](#index)
|
|
||||||
|
|
||||||
## MVP
|
|
||||||
- release
|
|
||||||
- blog
|
|
||||||
- update COPYING
|
|
||||||
- update README
|
|
||||||
- auto-populate data on initial launch
|
|
||||||
- audit + document API exposed to apps
|
|
||||||
- ssb core
|
|
||||||
- good refresh
|
|
||||||
- disconnect all current connections and reset reconnect timers?
|
|
||||||
- reload the page
|
|
||||||
- live updates
|
|
||||||
- createHistoryStream for every account followed from local accounts
|
|
||||||
- apps
|
|
||||||
- app messages
|
|
||||||
- installable apps
|
|
||||||
- web interface
|
|
||||||
- live updates
|
|
||||||
- strip out unnecessary things?
|
|
||||||
- more raw views until it's more functional?
|
|
||||||
|
|
||||||
## Done
|
|
||||||
- likely classes of script errors
|
|
||||||
- tf core
|
|
||||||
- good error feedback
|
|
||||||
- markdeep demo
|
|
||||||
- send blobs
|
|
||||||
|
|
||||||
## Later
|
|
||||||
- DB migration
|
|
||||||
- stop using CDNs
|
|
||||||
- collect loads of stats
|
|
||||||
- faster save - parallel / don't save unmodified
|
|
||||||
- test likely denials of service
|
|
||||||
- package standalone executable
|
|
||||||
- ideas
|
|
||||||
- visualizations / analysis of gps data
|
|
||||||
- good web interface for managing connections
|
|
||||||
- identity
|
|
||||||
- multiple identities
|
|
||||||
- tie identities to TF login accounts
|
|
||||||
- tf account timeout why
|
|
||||||
- make some demo apps
|
|
||||||
- rock paper scissors, somehow
|
|
||||||
- don't resave files that didn't change
|
|
@ -1 +0,0 @@
|
|||||||
{"type":"tildefriends-app","files":{"app.js":"&6uFJG2C0kZar1Aj+7p2/KzYEBXgmK/uJSt7aIJqenN4=.sha256","index.html":"&TFtniuUIVO7XeWCgwmqPAmuBzpGX6slxJQcPMEr+860=.sha256","vue-material.js":"&K5cdLqXYCENPak/TCINHQhyJhpS4G9DlZHGwoh/LF2g=.sha256"}}
|
|
@ -1,381 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
|
|
||||||
const k_posts_max = 20;
|
|
||||||
const k_votes_max = 100;
|
|
||||||
|
|
||||||
async function following(db, id) {
|
|
||||||
var o = await db.get(id + ":following");
|
|
||||||
const k_version = 4;
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
if (!f || f.version != k_version) {
|
|
||||||
f = {users: [], sequence: 0, version: k_version};
|
|
||||||
}
|
|
||||||
f.users = new Set(f.users);
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" sequence, "+
|
|
||||||
" json_extract(content, '$.contact') AS contact, "+
|
|
||||||
" json_extract(content, '$.following') AS following "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" author = ?1 AND "+
|
|
||||||
" sequence > ?2 AND "+
|
|
||||||
" json_extract(content, '$.type') = 'contact' "+
|
|
||||||
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
|
|
||||||
"ORDER BY sequence",
|
|
||||||
[id, f.sequence],
|
|
||||||
async function(row) {
|
|
||||||
if (row.following) {
|
|
||||||
f.users.add(row.contact);
|
|
||||||
} else {
|
|
||||||
f.users.delete(row.contact);
|
|
||||||
}
|
|
||||||
f.sequence = row.sequence;
|
|
||||||
});
|
|
||||||
f.users = Array.from(f.users);
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":following", j);
|
|
||||||
}
|
|
||||||
return f.users;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function followingDeep(db, seed_ids, depth) {
|
|
||||||
if (depth <= 0) {
|
|
||||||
return seed_ids;
|
|
||||||
}
|
|
||||||
var f = await Promise.all(seed_ids.map(x => following(db, x)));
|
|
||||||
var ids = [].concat(...f);
|
|
||||||
var x = await followingDeep(db, [...new Set(ids)].sort(), depth - 1);
|
|
||||||
x = [].concat(...x, ...seed_ids);
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function followers(db, id) {
|
|
||||||
var o = await db.get(id + ":followers");
|
|
||||||
const k_version = 2;
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
if (!f || f.version != k_version) {
|
|
||||||
f = {users: [], rowid: 0, version: k_version};
|
|
||||||
}
|
|
||||||
f.users = new Set(f.users);
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" rowid, "+
|
|
||||||
" author AS contact, "+
|
|
||||||
" json_extract(content, '$.following') AS following "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" rowid > $1 AND "+
|
|
||||||
" json_extract(content, '$.type') = 'contact' AND "+
|
|
||||||
" json_extract(content, '$.contact') = $2 "+
|
|
||||||
"UNION SELECT MAX(rowid) as rowid, NULL, NULL FROM messages "+
|
|
||||||
"ORDER BY rowid",
|
|
||||||
[f.rowid, id],
|
|
||||||
async function(row) {
|
|
||||||
if (row.following) {
|
|
||||||
f.users.add(row.contact);
|
|
||||||
} else {
|
|
||||||
f.users.delete(row.contact);
|
|
||||||
}
|
|
||||||
f.rowid = row.rowid;
|
|
||||||
});
|
|
||||||
f.users = Array.from(f.users);
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":followers", j);
|
|
||||||
}
|
|
||||||
return f.users;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendUser(db, id) {
|
|
||||||
return Promise.all([
|
|
||||||
following(db, id).then(async function(following) {
|
|
||||||
return app.postMessage({following: {id: id, users: following}});
|
|
||||||
}),
|
|
||||||
followers(db, id).then(async function(followers) {
|
|
||||||
return app.postMessage({followers: {id: id, users: followers}});
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pubsByUser(db, id) {
|
|
||||||
var o = await db.get(id + ":pubs");
|
|
||||||
const k_version = 2;
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
if (!f || f.version != k_version) {
|
|
||||||
f = {pubs: [], sequence: 0, version: k_version};
|
|
||||||
}
|
|
||||||
f.pubs = Object.fromEntries(f.pubs.map(x => [JSON.stringify(x), x]));
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" sequence, "+
|
|
||||||
" json_extract(content, '$.address.host') AS host, "+
|
|
||||||
" json_extract(content, '$.address.port') AS port, "+
|
|
||||||
" json_extract(content, '$.address.key') AS key "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" sequence > ?1 AND "+
|
|
||||||
" author = ?2 AND "+
|
|
||||||
" json_extract(content, '$.type') = 'pub' "+
|
|
||||||
"UNION SELECT MAX(sequence) as sequence, NULL, NULL, NULL FROM messages WHERE author = ?2 "+
|
|
||||||
"ORDER BY sequence",
|
|
||||||
[f.sequence, id],
|
|
||||||
async function(row) {
|
|
||||||
f.sequence = row.sequence;
|
|
||||||
if (row.host) {
|
|
||||||
row = {host: row.host, port: row.port, key: row.key};
|
|
||||||
f.pubs[JSON.stringify(row)] = row;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
f.pubs = Object.values(f.pubs);
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":pubs", j);
|
|
||||||
}
|
|
||||||
return f.pubs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function visiblePubs(db, id) {
|
|
||||||
var ids = [id].concat(await following(db, id));
|
|
||||||
var pubs = {};
|
|
||||||
for (var follow of ids) {
|
|
||||||
var followPubs = await pubsByUser(db, follow);
|
|
||||||
for (var pub of followPubs) {
|
|
||||||
pubs[JSON.stringify(pub)] = pub;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.values(pubs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAbout(db, id) {
|
|
||||||
var o = await db.get(id + ":about");
|
|
||||||
const k_version = 4;
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
if (!f || f.version != k_version) {
|
|
||||||
f = {about: {}, sequence: 0, version: k_version};
|
|
||||||
}
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" sequence, "+
|
|
||||||
" content "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" sequence > ?1 AND "+
|
|
||||||
" author = ?2 AND "+
|
|
||||||
" json_extract(content, '$.type') = 'about' AND "+
|
|
||||||
" json_extract(content, '$.about') = author "+
|
|
||||||
"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?2 "+
|
|
||||||
"ORDER BY sequence",
|
|
||||||
[f.sequence, id],
|
|
||||||
async function(row) {
|
|
||||||
f.sequence = row.sequence;
|
|
||||||
if (row.content) {
|
|
||||||
var about = {};
|
|
||||||
try {
|
|
||||||
about = JSON.parse(row.content);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
delete about.about;
|
|
||||||
delete about.type;
|
|
||||||
f.about = Object.assign(f.about, about);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":about", j);
|
|
||||||
}
|
|
||||||
return f.about;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fnv32a(value)
|
|
||||||
{
|
|
||||||
var result = 0x811c9dc5;
|
|
||||||
for (var i = 0; i < value.length; i++) {
|
|
||||||
result ^= value.charCodeAt(i);
|
|
||||||
result += (result << 1) + (result << 4) + (result << 7) + (result << 8) + (result << 24);
|
|
||||||
}
|
|
||||||
return result >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getRecentPostIds(db, id, ids, limit) {
|
|
||||||
const k_version = 6;
|
|
||||||
var o = await db.get(id + ':recent_posts');
|
|
||||||
var recent = [];
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
var ids_hash = fnv32a(JSON.stringify(ids));
|
|
||||||
if (!f || f.version != k_version || f.ids_hash != ids_hash) {
|
|
||||||
f = {recent: [], rowid: 0, version: k_version, ids_hash: ids_hash};
|
|
||||||
}
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" rowid, "+
|
|
||||||
" id "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" rowid > ? AND "+
|
|
||||||
" author IN (" + ids.map(x => '?').join(", ") + ") AND "+
|
|
||||||
" json_extract(content, '$.type') = 'post' "+
|
|
||||||
"UNION SELECT MAX(rowid) as rowid, NULL FROM messages "+
|
|
||||||
"ORDER BY rowid DESC LIMIT ?",
|
|
||||||
[].concat([f.rowid], ids, [limit + 1]),
|
|
||||||
function(row) {
|
|
||||||
if (row.id) {
|
|
||||||
recent.push(row.id);
|
|
||||||
}
|
|
||||||
if (row.rowid) {
|
|
||||||
f.rowid = row.rowid;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
f.recent = [].concat(recent, f.recent).slice(0, limit);
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":recent_posts", j);
|
|
||||||
}
|
|
||||||
return f.recent;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getVotes(db, id) {
|
|
||||||
var o = await db.get(id + ":votes");
|
|
||||||
const k_version = 2;
|
|
||||||
var votes = [];
|
|
||||||
var f = o ? JSON.parse(o) : o;
|
|
||||||
if (!f || f.version != k_version) {
|
|
||||||
f = {votes: [], rowid: 0, version: k_version};
|
|
||||||
}
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT "+
|
|
||||||
" rowid, "+
|
|
||||||
" author, "+
|
|
||||||
" id, "+
|
|
||||||
" sequence, "+
|
|
||||||
" timestamp, "+
|
|
||||||
" content "+
|
|
||||||
"FROM messages "+
|
|
||||||
"WHERE "+
|
|
||||||
" rowid > ? AND "+
|
|
||||||
" author = ? AND "+
|
|
||||||
" json_extract(content, '$.type') = 'vote' "+
|
|
||||||
"UNION SELECT MAX(rowid) as rowid, NULL, NULL AS id, NULL, NULL, NULL FROM messages "+
|
|
||||||
"ORDER BY rowid DESC LIMIT ?",
|
|
||||||
[f.rowid, id, k_votes_max],
|
|
||||||
async function(row) {
|
|
||||||
if (row.id) {
|
|
||||||
votes.push(row);
|
|
||||||
} else {
|
|
||||||
f.rowid = row.rowid;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
f.votes = [].concat(votes.reverse(), f.votes).slice(0, k_votes_max);
|
|
||||||
var j = JSON.stringify(f);
|
|
||||||
if (o != j) {
|
|
||||||
await db.set(id + ":votes", j);
|
|
||||||
}
|
|
||||||
return f.votes;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPosts(db, ids) {
|
|
||||||
var posts = [];
|
|
||||||
if (ids.length) {
|
|
||||||
await ssb.sqlStream(
|
|
||||||
"SELECT rowid, * FROM messages WHERE id IN (" + ids.map(x => "?").join(", ") + ")",
|
|
||||||
ids,
|
|
||||||
async function(row) {
|
|
||||||
try {
|
|
||||||
posts.push(row);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return posts;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ready() {
|
|
||||||
return refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
core.register('onBroadcastsChanged', async function() {
|
|
||||||
await app.postMessage({broadcasts: await ssb.getBroadcasts()});
|
|
||||||
});
|
|
||||||
|
|
||||||
core.register('onConnectionsChanged', async function() {
|
|
||||||
var connections = await ssb.connections();
|
|
||||||
await app.postMessage({connections: connections});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
await app.postMessage({clear: true});
|
|
||||||
var whoami = await ssb.whoami();
|
|
||||||
var db = await database("ssb");
|
|
||||||
await Promise.all([
|
|
||||||
app.postMessage({whoami: whoami}),
|
|
||||||
app.postMessage({pubs: await visiblePubs(db, whoami)}),
|
|
||||||
app.postMessage({broadcasts: await ssb.getBroadcasts()}),
|
|
||||||
app.postMessage({connections: await ssb.connections()}),
|
|
||||||
followingDeep(db, [whoami], 2).then(function(f) {
|
|
||||||
getRecentPostIds(db, whoami, [].concat([whoami], f), k_posts_max).then(async function(ids) {
|
|
||||||
return getPosts(db, ids);
|
|
||||||
}).then(async function(posts) {
|
|
||||||
var roots = posts.map(function(x) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(x.content).root;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
roots = roots.filter(function(root) {
|
|
||||||
return root && posts.every(post => post.id != root);
|
|
||||||
});
|
|
||||||
return [].concat(posts, await getPosts(db, roots));
|
|
||||||
}).then(async function(posts) {
|
|
||||||
posts.forEach(async function(post) {
|
|
||||||
await app.postMessage({message: post});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
f.forEach(async function(id) {
|
|
||||||
await Promise.all([
|
|
||||||
getVotes(db, id).then(async function(votes) {
|
|
||||||
return Promise.all(votes.map(vote => app.postMessage({vote: vote})));
|
|
||||||
}),
|
|
||||||
getAbout(db, id).then(async function(user) {
|
|
||||||
return app.postMessage({user: {user: id, about: user}});
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
sendUser(db, whoami),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
core.register('message', async function(m) {
|
|
||||||
if (m.message == 'ready') {
|
|
||||||
await ready();
|
|
||||||
} else if (m.message) {
|
|
||||||
if (m.message.connect) {
|
|
||||||
await ssb.connect(m.message.connect);
|
|
||||||
} else if (m.message.post) {
|
|
||||||
await ssb.post(m.message.post);
|
|
||||||
} else if (m.message.appendMessage) {
|
|
||||||
await ssb.appendMessage(m.message.appendMessage);
|
|
||||||
} else if (m.message.user) {
|
|
||||||
await sendUser(await database("ssb"), m.message.user);
|
|
||||||
} else if (m.message.refresh) {
|
|
||||||
await refresh();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
print(JSON.stringify(m));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (core.user &&
|
|
||||||
core.user.credentials &&
|
|
||||||
core.user.credentials.permissions &&
|
|
||||||
core.user.credentials.permissions.administration) {
|
|
||||||
await app.setDocument(utf8Decode(await getFile("index.html")));
|
|
||||||
} else {
|
|
||||||
await app.setDocument('<div style="color: #f00">Only the administrator can use this app at this time. Login at the top right.</div>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
@ -1,281 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta content="width=device-width,initial-scale=1,minimal-ui" name="viewport">
|
|
||||||
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto:400,500,700,400italic|Material+Icons">
|
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/vue-material.min.css">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/vue-material/dist/theme/default-dark.css">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
|
|
||||||
<script src="vue-material.js"></script>
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/commonmark/0.29.1/commonmark.min.js"></script>
|
|
||||||
<script>
|
|
||||||
var g_data = {
|
|
||||||
whoami: null,
|
|
||||||
connections: [],
|
|
||||||
messages: [],
|
|
||||||
users: {},
|
|
||||||
broadcasts: [],
|
|
||||||
showUsers: false,
|
|
||||||
show_connect_dialog: false,
|
|
||||||
show_user_dialog: null,
|
|
||||||
connect: null,
|
|
||||||
pubs: [],
|
|
||||||
votes: [],
|
|
||||||
};
|
|
||||||
var g_data_initial = JSON.parse(JSON.stringify(g_data));
|
|
||||||
window.addEventListener('message', function(event) {
|
|
||||||
var key = Object.keys(event.data)[0];
|
|
||||||
if (key + 's' in g_data && Array.isArray(g_data[key + 's'])) {
|
|
||||||
g_data[key + 's'].push(event.data[key]);
|
|
||||||
} else if (key == 'user') {
|
|
||||||
Vue.set(g_data.users, event.data.user.user, Object.assign({}, g_data.users[event.data.user.user] || {}, event.data.user.about));
|
|
||||||
} else if (key == 'followers') {
|
|
||||||
if (!g_data.users[event.data.followers.id]) {
|
|
||||||
Vue.set(g_data.users, event.data.followers.id, {});
|
|
||||||
}
|
|
||||||
Vue.set(g_data.users[event.data.followers.id], 'followers', event.data.followers.users);
|
|
||||||
} else if (key == 'following') {
|
|
||||||
if (!g_data.users[event.data.following.id]) {
|
|
||||||
Vue.set(g_data.users, event.data.following.id, {});
|
|
||||||
}
|
|
||||||
Vue.set(g_data.users[event.data.following.id], 'following', event.data.following.users);
|
|
||||||
} else if (key == 'broadcasts') {
|
|
||||||
g_data.broadcasts = event.data.broadcasts;
|
|
||||||
} else if (key == 'pubs') {
|
|
||||||
g_data.pubs = event.data.pubs;
|
|
||||||
} else if (key == 'clear') {
|
|
||||||
Object.keys(g_data_initial).forEach(function(key) {
|
|
||||||
Vue.set(g_data, key, JSON.parse(JSON.stringify(g_data_initial[key])));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
g_data[key] = event.data[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
Vue.use(VueMaterial.default);
|
|
||||||
Vue.component('tf-user', {
|
|
||||||
data: function() { return {users: g_data.users, show_user_dialog: false, show_follow_dialog: false} },
|
|
||||||
props: ['id'],
|
|
||||||
mounted: function() {
|
|
||||||
window.parent.postMessage({user: this.id}, '*');
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
following: {
|
|
||||||
get: function() {
|
|
||||||
return g_data.users[g_data.whoami] &&
|
|
||||||
g_data.users[g_data.whoami].following &&
|
|
||||||
g_data.users[g_data.whoami].following.indexOf(this.id) != -1;
|
|
||||||
},
|
|
||||||
set: function(newValue) {
|
|
||||||
if (g_data.users[g_data.whoami] &&
|
|
||||||
g_data.users[g_data.whoami].following) {
|
|
||||||
if (newValue && g_data.users[g_data.whoami].following.indexOf(this.id) == -1) {
|
|
||||||
window.parent.postMessage({appendMessage: {type: "contact", following: true, contact: this.id}}, '*');
|
|
||||||
} else if (!newValue) {
|
|
||||||
window.parent.postMessage({appendMessage: {type: "contact", following: false, contact: this.id}}, '*');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: `<span @click="show_user_dialog = true">
|
|
||||||
{{users[id] && users[id].name ? users[id].name : id}}
|
|
||||||
<md-tooltip v-if="users[id] && users[id].name">{{id}}</md-tooltip>
|
|
||||||
<md-dialog :md-active.sync="show_user_dialog">
|
|
||||||
<md-dialog-title>{{users[id] && users[id].name ? users[id].name : id}}</md-dialog-title>
|
|
||||||
<md-dialog-content v-if="users[id]">
|
|
||||||
<div v-if="users[id].image"><img :src="'/' + users[id].image + '/view'"></div>
|
|
||||||
<div v-if="users[id].name">{{id}}</div>
|
|
||||||
<div>{{users[id].description}}</div>
|
|
||||||
<div><md-switch v-model="following">Following</md-switch></div>
|
|
||||||
<md-list>
|
|
||||||
<md-subheader>Followers</md-subheader>
|
|
||||||
<md-list-item v-for="follower in (users[id] || []).followers" v-bind:key="'follower-' + follower">
|
|
||||||
<tf-user :id="follower"></tf-user>
|
|
||||||
</md-list-item>
|
|
||||||
<md-subheader>Following</md-subheader>
|
|
||||||
<md-list-item v-for="user in (users[id] || []).following" v-bind:key="'following-' + user">
|
|
||||||
<tf-user :id="user"></tf-user>
|
|
||||||
</md-list-item>
|
|
||||||
</md-list>
|
|
||||||
</md-dialog-content>
|
|
||||||
<md-dialog-actions>
|
|
||||||
<md-button @click="show_user_dialog = false">Close</md-button>
|
|
||||||
</md-dialog-actions>
|
|
||||||
</md-dialog>
|
|
||||||
</span>`,
|
|
||||||
});
|
|
||||||
Vue.component('tf-message', {
|
|
||||||
props: ['message', 'messages'],
|
|
||||||
data: function() { return { showRaw: false } },
|
|
||||||
computed: {
|
|
||||||
content_json: function() {
|
|
||||||
try {
|
|
||||||
return JSON.parse(this.message.content);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sub_messages: function() {
|
|
||||||
var id = this.message.id;
|
|
||||||
return this.messages.filter(function (x) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(x.content).root == id;
|
|
||||||
} catch {}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
votes: function() {
|
|
||||||
return [];
|
|
||||||
var id = this.message.id;
|
|
||||||
return this.votes.filter(function (x) {
|
|
||||||
try {
|
|
||||||
var j = JSON.parse(x.content);
|
|
||||||
return j.type == 'vote' && j.vote.link == id;
|
|
||||||
} catch {}
|
|
||||||
}).reduce(function (accum, value) {
|
|
||||||
var expression = JSON.parse(value.content).vote.expression;
|
|
||||||
if (!accum[expression]) {
|
|
||||||
accum[expression] = [];
|
|
||||||
}
|
|
||||||
accum[expression].push(value);
|
|
||||||
return accum;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
markdown: function(md) {
|
|
||||||
var reader = new commonmark.Parser({safe: true});
|
|
||||||
var writer = new commonmark.HtmlRenderer();
|
|
||||||
return writer.render(reader.parse(md));
|
|
||||||
},
|
|
||||||
json: function(message) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(message.content);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
template: `<md-app class="md-elevation-8" style="margin: 1em" v-if="!content_json || ['pub', 'vote'].indexOf(content_json.type) == -1">
|
|
||||||
<md-app-toolbar>
|
|
||||||
<h3>
|
|
||||||
<tf-user :id="message.author"></tf-user>
|
|
||||||
</h3>
|
|
||||||
<div style="font-size: x-small">{{new Date(message.timestamp)}}</div>
|
|
||||||
<div class="md-toolbar-section-end">
|
|
||||||
<md-menu>
|
|
||||||
<md-button md-menu-trigger class="md-icon-button"><md-icon>more_vert</md-icon></md-button>
|
|
||||||
<md-menu-content>
|
|
||||||
<md-menu-item v-if="!showRaw" v-on:click="showRaw = true">View Raw</md-menu-item>
|
|
||||||
<md-menu-item v-else v-on:click="showRaw = false">View Message</md-menu-item>
|
|
||||||
</md-menu-content>
|
|
||||||
</md-menu>
|
|
||||||
</div>
|
|
||||||
</md-app-toolbar>
|
|
||||||
<md-app-content>
|
|
||||||
<div v-if="showRaw">{{message.content}}</div>
|
|
||||||
<div v-else>
|
|
||||||
<div v-if="content_json && content_json.type == 'post'">
|
|
||||||
<div v-html="this.markdown(content_json.text)"></div>
|
|
||||||
<img v-for="mention in content_json.mentions" v-if="mention.link && typeof(mention.link) == 'string' && mention.link.startsWith('&')" :src="'/' + mention.link + '/view'"></img>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="content_json && content_json.type == 'contact'"><tf-user :id="message.author"></tf-user> {{content_json.following ? '==>' : '=/=>'}} <tf-user :id="content_json.contact"></tf-user></div>
|
|
||||||
<div v-else>{{message.content}}</div>
|
|
||||||
</div>
|
|
||||||
<tf-message v-for="sub_message in sub_messages" v-bind:message="sub_message" v-bind:messages="messages" v-bind:key="sub_message.id"></tf-message>
|
|
||||||
<md-chip v-for="vote in Object.keys(votes)" v-bind:key="vote">
|
|
||||||
{{vote + (votes[vote].length > 1 ? ' (' + votes[vote].length + ')' : '')}}
|
|
||||||
</md-chip>
|
|
||||||
</md-app-content>
|
|
||||||
</md-app>`,
|
|
||||||
});
|
|
||||||
function markdown(d) { return d; }
|
|
||||||
Vue.config.performance = true;
|
|
||||||
var vue = new Vue({
|
|
||||||
el: '#app',
|
|
||||||
data: g_data,
|
|
||||||
methods: {
|
|
||||||
post_message: function() {
|
|
||||||
window.parent.postMessage({post: document.getElementById('post_text').value}, '*');
|
|
||||||
},
|
|
||||||
ssb_connect: function(connection) {
|
|
||||||
window.parent.postMessage({connect: connection}, '*');
|
|
||||||
},
|
|
||||||
content_json: function(message) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(message.content);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
refresh: function() {
|
|
||||||
window.parent.postMessage({refresh: true}, '*');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
window.parent.postMessage('ready', '*');
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body style="color: #fff">
|
|
||||||
<div id="app">
|
|
||||||
<md-dialog :md-active.sync="show_connect_dialog">
|
|
||||||
<md-dialog-title>Connect</md-dialog-title>
|
|
||||||
<md-dialog-content>
|
|
||||||
<md-field>
|
|
||||||
<label>net:127.0.0.1:8008~shs:id</label>
|
|
||||||
<md-input v-model="connect"></md-input>
|
|
||||||
</md-field>
|
|
||||||
</md-dialog-content>
|
|
||||||
<md-dialog-actions>
|
|
||||||
<md-button class="md-primary" @click="ssb_connect(connect); connect = null; show_connect_dialog = false">Connect</md-button>
|
|
||||||
<md-button @click="connect = null; show_connect_dialog = false">Cancel</md-button>
|
|
||||||
</md-dialog-actions>
|
|
||||||
</md-dialog>
|
|
||||||
<md-app style="position: absolute; height: 100%; width: 100%">
|
|
||||||
<md-app-toolbar class="md-primary">
|
|
||||||
<md-button class="md-icon-button" @click="showUsers = !showUsers">
|
|
||||||
<md-icon>menu</md-icon>
|
|
||||||
</md-button>
|
|
||||||
<span class="md-title">Tilde Friends Secure Scuttlebutt Test</span>
|
|
||||||
</md-app-toolbar>
|
|
||||||
<md-app-drawer :md-active.sync="showUsers" md-persistent="full">
|
|
||||||
<md-list>
|
|
||||||
<md-subheader>Followers</md-subheader>
|
|
||||||
<md-list-item v-for="follower in (users[whoami] || []).followers" v-bind:key="'follower-' + follower"><tf-user :id="follower"></tf-user></md-list-item>
|
|
||||||
<md-subheader>Following</md-subheader>
|
|
||||||
<md-list-item v-for="user in (users[whoami] || []).following" v-bind:key="'following-' + user"><tf-user :id="user"></tf-user></md-list-item>
|
|
||||||
<md-subheader>Network</md-subheader>
|
|
||||||
<md-list-item v-for="broadcast in broadcasts" v-bind:key="JSON.stringify(broadcast)" @click="ssb_connect(broadcast)">{{broadcast.address}}:{{broadcast.port}} <tf-user :id="broadcast.pubkey"></tf-user></md-list-item>
|
|
||||||
<md-subheader>Pubs</md-subheader>
|
|
||||||
<md-list-item v-for="pub in pubs" v-bind:key="JSON.stringify(pub)" @click="ssb_connect({address: pub.host, port: pub.port, pubkey: pub.key})">{{pub.host}}:{{pub.port}} <tf-user :id="pub.key"></tf-user></md-list-item>
|
|
||||||
<md-subheader>Connections</md-subheader>
|
|
||||||
<md-list-item v-for="connection in connections" v-bind:key="'connection-' + JSON.stringify(connection)"><tf-user :id="connection"></tf-user></md-list-item>
|
|
||||||
<md-list-item @click="show_connect_dialog = true">Connect</md-list-item>
|
|
||||||
</md-list>
|
|
||||||
</md-app-drawer>
|
|
||||||
<md-app-content>
|
|
||||||
<md-button @click="refresh()" class="md-icon-button md-dense md-raised md-primary">
|
|
||||||
<md-icon>cached</md-icon>
|
|
||||||
</md-button>
|
|
||||||
Welcome, <tf-user :id="whoami"></tf-user>.
|
|
||||||
<md-card class="md-elevation-8">
|
|
||||||
<md-card-header>
|
|
||||||
<div class="md-title">What's up?</div>
|
|
||||||
</md-card-header>
|
|
||||||
<md-card-content>
|
|
||||||
<md-field>
|
|
||||||
<label>Post a message</label>
|
|
||||||
<md-textarea id="post_text"></md-textarea>
|
|
||||||
</md-field>
|
|
||||||
</md-card-content>
|
|
||||||
<md-card-actions>
|
|
||||||
<md-button class="md-raised md-primary" v-on:click="post_message()">Submit Post</md-button>
|
|
||||||
</md-card-actions>
|
|
||||||
</md-card>
|
|
||||||
<tf-message v-for="message in messages" v-if="!content_json(message).root" v-bind:message="message" v-bind:messages="messages" v-bind:key="message.id"></tf-message>
|
|
||||||
</md-app-content>
|
|
||||||
</md-app>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
File diff suppressed because one or more lines are too long
4
apps/db.json
Normal file
4
apps/db.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "💽"
|
||||||
|
}
|
70
apps/db/app.js
Normal file
70
apps/db/app.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
async function database_list() {
|
||||||
|
var dbs = await databases();
|
||||||
|
var doc = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="background: #888">
|
||||||
|
<h1>Databases</h1>
|
||||||
|
<ul id="dbs"></ul>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
function populate_dbs(id, dbs) {
|
||||||
|
var list = document.getElementById(id);
|
||||||
|
for (let db of dbs) {
|
||||||
|
var li = list.appendChild(document.createElement('li'));
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.innerText = db;
|
||||||
|
a.href = './#' + db;
|
||||||
|
a.target = '_top';
|
||||||
|
li.appendChild(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
populate_dbs('dbs', ${JSON.stringify(dbs)});
|
||||||
|
</script>
|
||||||
|
</html>`;
|
||||||
|
app.setDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function key_list(db) {
|
||||||
|
let keys = await db.getAll();
|
||||||
|
let object = {};
|
||||||
|
for (let key of keys) {
|
||||||
|
object[key] = await db.get(key);
|
||||||
|
}
|
||||||
|
let doc = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body style="background: #888">
|
||||||
|
<a href="#" target="_top">back</a>
|
||||||
|
<h1>Keys</h1>
|
||||||
|
<ul id="keys"></ul>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
function populate_dbs(id, keys) {
|
||||||
|
var list = document.getElementById(id);
|
||||||
|
for (let [key, value] of Object.entries(keys)) {
|
||||||
|
var li = list.appendChild(document.createElement('li'));
|
||||||
|
li.innerText = key + ' = ' + value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
populate_dbs('keys', ${JSON.stringify(object)});
|
||||||
|
</script>
|
||||||
|
</html>`;
|
||||||
|
app.setDocument(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
core.register('message', async function(message) {
|
||||||
|
if (message.event == 'hashChange') {
|
||||||
|
let hash = message.hash.substring(1);
|
||||||
|
if (hash.startsWith(':shared:')) {
|
||||||
|
let parts = hash.split(':');
|
||||||
|
let packageName = parts[3];
|
||||||
|
let key = parts.slice(4).join(':');
|
||||||
|
key_list(await my_shared_database(packageName, key));
|
||||||
|
} else if (hash.length) {
|
||||||
|
key_list(await database(hash.split(':').slice(1).join(':')));
|
||||||
|
} else {
|
||||||
|
database_list();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
database_list();
|
4
apps/docs.json
Normal file
4
apps/docs.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "📚"
|
||||||
|
}
|
29
apps/docs/app.js
Normal file
29
apps/docs/app.js
Normal file
File diff suppressed because one or more lines are too long
12
apps/docs/index.md
Normal file
12
apps/docs/index.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# Tilde Friends Documentation
|
||||||
|
|
||||||
|
Tilde Friends is a participating member of a greater social
|
||||||
|
network, [Secure Scuttlebutt](https://scuttlebutt.nz/),
|
||||||
|
adding a way to safely and securely write, share,
|
||||||
|
and run code in the form of server-side web applications.
|
||||||
|
|
||||||
|
- [Tilde Friends Vision](#vision)
|
||||||
|
- [Secure Scuttlebutt from Scratch](#ssb)
|
||||||
|
- [Structure](#structure)
|
||||||
|
- [Guide](#guide)
|
||||||
|
- [TODO](#todo)
|
41
apps/docs/ssb.md
Normal file
41
apps/docs/ssb.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Secure Scuttlebutt from Scratch
|
||||||
|
[Back to index](#index)
|
||||||
|
|
||||||
|
This aims to be the missing reference for those who wish to create a Secure
|
||||||
|
Scuttlebutt client from scratch.
|
||||||
|
|
||||||
|
## Discovery
|
||||||
|
A good way to get started is to participate in local network discovery with a known working
|
||||||
|
client on the same network. The
|
||||||
|
[Scuttlebutt Programming Guide](https://ssbc.github.io/scuttlebutt-protocol-guide/#local-network)
|
||||||
|
is a good start, here, with a few things to note:
|
||||||
|
|
||||||
|
1. Some clients advertise multiple addresses separated by semicolons (`;`).
|
||||||
|
2. Some clients advertise alternative protocols than `shs` and use hostnames instead of
|
||||||
|
IPv4 addresses.
|
||||||
|
|
||||||
|
So be prepared to accept variations.
|
||||||
|
|
||||||
|
There also an undocumented "new" style of discovery message.
|
||||||
|
|
||||||
|
## Secret Handshake, Box Stream, and RPC Protocol
|
||||||
|
Now that two clients are aware of eachother, they need to complete a secret handshake.
|
||||||
|
The [programming guide](https://ssbc.github.io/scuttlebutt-protocol-guide/#handshake)
|
||||||
|
is once again a good reference.
|
||||||
|
|
||||||
|
The box stream and RPC protocol can both be implemented from the
|
||||||
|
[same documentation](https://ssbc.github.io/scuttlebutt-protocol-guide/#box-stream)
|
||||||
|
without surprises.
|
||||||
|
|
||||||
|
## Synchronizing Data
|
||||||
|
|
||||||
|
... `ebt.replicate` or `createHistoryStream` ...
|
||||||
|
|
||||||
|
## Rooms
|
||||||
|
|
||||||
|
TODO
|
||||||
|
|
||||||
|
## References
|
||||||
|
* [https://ssbc.github.io/scuttlebutt-protocol-guide/](https://ssbc.github.io/scuttlebutt-protocol-guide/)
|
||||||
|
* [https://dev.planetary.social/](https://dev.planetary.social/)
|
||||||
|
* [https://dev.scuttlebutt.nz/#/golang/?id=muxrpc-endpoints](https://dev.scuttlebutt.nz/#/golang/?id=muxrpc-endpoints)
|
@ -21,7 +21,7 @@ In combines the following key components:
|
|||||||
are mediated through the core process.
|
are mediated through the core process.
|
||||||
|
|
||||||
When run with no arguments, it starts a web server on
|
When run with no arguments, it starts a web server on
|
||||||
[http://localhost:12345/](http://localhost:12345/) and an SSB server.
|
[http://localhost:12345/](http://localhost:12345/) and an SSB node.
|
||||||
|
|
||||||
## Web Interface
|
## Web Interface
|
||||||
The Tilde Friends web server provides access to Tilde Friends applications,
|
The Tilde Friends web server provides access to Tilde Friends applications,
|
||||||
@ -57,10 +57,8 @@ browser. This process has a custom RPC connection to the core process
|
|||||||
which holds the WebSocket connection to the browser.
|
which holds the WebSocket connection to the browser.
|
||||||
|
|
||||||
The custom RPC communication between the sandbox process and the core
|
The custom RPC communication between the sandbox process and the core
|
||||||
process facilitates calling functions asynchronously. Calling a remote
|
process facilitates passing and calling functions remotely. Calling a
|
||||||
function (ie. a function in another process) returns a `Promise`. In
|
function in another process returns a `Promise`.
|
||||||
addition, any functions passed in either direction are serialized in
|
|
||||||
such a way that they can be called remotely.
|
|
||||||
|
|
||||||
An application will typically call `app.setDocument()` at startup to
|
An application will typically call `app.setDocument()` at startup to
|
||||||
populate the app's iframe in the web browser with its own client web
|
populate the app's iframe in the web browser with its own client web
|
63
apps/docs/todo.md
Normal file
63
apps/docs/todo.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Tilde Friends TODO
|
||||||
|
[Back to index](#index)
|
||||||
|
|
||||||
|
## MVP3
|
||||||
|
- Sync status (problem feeds, messages/seconds stats, ...)
|
||||||
|
- app: wiki
|
||||||
|
- app: public blog
|
||||||
|
- Content-Disposition: download
|
||||||
|
- remove SSB credentials
|
||||||
|
- export SSB credentials
|
||||||
|
- initial: better empty news screen
|
||||||
|
- initial: remembered wrong user across login/logout
|
||||||
|
- initial: bad experience when following nobody
|
||||||
|
- make a cool independent app
|
||||||
|
- indicate when workspace differs from installed
|
||||||
|
- / => Something good.
|
||||||
|
- update docs
|
||||||
|
- audit + document API exposed to apps
|
||||||
|
- fix weird HTTP warnings
|
||||||
|
- channels
|
||||||
|
- placeholder/missing images
|
||||||
|
- no denial of service
|
||||||
|
- package standalone executable
|
||||||
|
- editor without app iframe
|
||||||
|
- sequence_before_author -> flags
|
||||||
|
- linkify ssb: links
|
||||||
|
- perfect rooms support
|
||||||
|
- connections 2.0
|
||||||
|
- make a better connections API
|
||||||
|
|
||||||
|
## Maybe Done
|
||||||
|
- blob_wants 2.0
|
||||||
|
- image downsample
|
||||||
|
- app: todo
|
||||||
|
- app: build archive
|
||||||
|
- update README
|
||||||
|
- administrators config
|
||||||
|
- apps name characters
|
||||||
|
- initial: can't switch to account when there is only one
|
||||||
|
- get tarball under 5MB
|
||||||
|
- rooms
|
||||||
|
- initial: doesn't refresh when create identity
|
||||||
|
- tf account timeout why
|
||||||
|
- ssb don't overflow boxes
|
||||||
|
- jwt for session tokens
|
||||||
|
- linkify https://...
|
||||||
|
- emoji reaction picker
|
||||||
|
- expose loads of stats
|
||||||
|
- confirm posting all new messages
|
||||||
|
- multiple identities per user, in database
|
||||||
|
- auto-populate data on initial launch
|
||||||
|
- make the docker image good / test it / use it
|
||||||
|
- leaking imports / exports
|
||||||
|
- file upload widget
|
||||||
|
- keep working on good error feedback
|
||||||
|
- build for windows
|
||||||
|
- installable apps (bring back an app message?)
|
||||||
|
- sqlStream => sqlExec or something
|
||||||
|
- !ssb from child process?
|
||||||
|
|
||||||
|
## Done
|
||||||
|
- update LICENSE
|
||||||
|
- logging to browser
|
62
apps/docs/vision.md
Normal file
62
apps/docs/vision.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Tilde Friends Vision
|
||||||
|
[Back to index](#index)
|
||||||
|
|
||||||
|
Tilde Friends is a tool for making and sharing.
|
||||||
|
|
||||||
|
It is both a peer-to-peer social network client, participating in Secure
|
||||||
|
Scuttlebutt, and an environment for creating and running web applications.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
This is a thing that I wanted to exist and wanted to work on. No other reason.
|
||||||
|
There is not a business model. I believe it is interesting and unique.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Ways to Use Tilde Friends
|
||||||
|
1. **Social Network User**: This is a social network first. You are just here,
|
||||||
|
because your friends are. Or you like how we limit your message length or
|
||||||
|
short videos or whatever the trend is. If you are ambitious, you click links
|
||||||
|
and see interactive experiences (apps) that you wouldn't see elsewhere.
|
||||||
|
|
||||||
|
2. **Web Visitor**: You get links from a friend to meeting invites, polls, games,
|
||||||
|
lists, wiki pages, ..., and you interact with them as though they were
|
||||||
|
cloud-hosted by a megacorporation. They just work, and you don't think twice.
|
||||||
|
|
||||||
|
3. **Group leader**: You host or use a small public instance, installing apps for
|
||||||
|
a group of friends to use as web visitors.
|
||||||
|
|
||||||
|
4. **Developer**: You like to write code and make or improve apps for fun or to
|
||||||
|
solve problems. When you encounter a Tilde Friends app on a strange server,
|
||||||
|
you know you can trivially modify it or download it to your own instance.
|
||||||
|
|
||||||
|
## Future Goals / Endgame
|
||||||
|
1. Mobile apps. This can run on your old phone. Maybe you won't be hosting
|
||||||
|
the web interface publicly, but you can sync, install and edit apps, and
|
||||||
|
otherwise get the full experience from a tiny touch screen.
|
||||||
|
|
||||||
|
2. The universal application runtime. The web browser is the universal
|
||||||
|
platform, but even for the simplest application that you might want to host
|
||||||
|
for your friends, cloud hosting, containers, and complicated dependencies might
|
||||||
|
all enter the mix. Tilde Friends, though it is yet another thing to host,
|
||||||
|
includes everything you need out of the box to run a vast variety of interesting
|
||||||
|
apps.
|
||||||
|
|
||||||
|
Tilde Friends will be built out, gradually providing safe access to host
|
||||||
|
resources and client resources the same way web browsers extended access to
|
||||||
|
resources like GPU, persistent storage, cameras, ... over the years.
|
||||||
|
|
||||||
|
Not much effort has been put forward yet to having a robust, long-lasting API,
|
||||||
|
but since the client side longevity is already handled by web browsers, it
|
||||||
|
seems possible that the server-side API can be managed in a similar way.
|
||||||
|
|
||||||
|
3. An awesome development environment. Right now it runs JavaScript from the
|
||||||
|
first embeddable text editor I could poorly configure enough to edit code,
|
||||||
|
but it could incorporate a debugger, source control integration a la ssb-git,
|
||||||
|
merge tools, and transpiling from all sorts of different languages.
|
4
apps/follow.json
Normal file
4
apps/follow.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "➡️"
|
||||||
|
}
|
267
apps/follow/app.js
Normal file
267
apps/follow/app.js
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
let g_about_cache = {};
|
||||||
|
|
||||||
|
async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contacts_internal(id, last_row_id, following, max_row_id) {
|
||||||
|
let result = Object.assign({}, following[id] || {});
|
||||||
|
result.following = result.following || {};
|
||||||
|
result.blocking = result.blocking || {};
|
||||||
|
let contacts = await query(
|
||||||
|
`
|
||||||
|
SELECT content FROM messages
|
||||||
|
WHERE author = ? AND
|
||||||
|
rowid > ? AND
|
||||||
|
rowid <= ? AND
|
||||||
|
json_extract(content, '$.type') = 'contact'
|
||||||
|
ORDER BY sequence
|
||||||
|
`,
|
||||||
|
[id, last_row_id, max_row_id]);
|
||||||
|
for (let row of contacts) {
|
||||||
|
let contact = JSON.parse(row.content);
|
||||||
|
if (contact.following === true) {
|
||||||
|
result.following[contact.contact] = true;
|
||||||
|
} else if (contact.following === false) {
|
||||||
|
delete result.following[contact.contact];
|
||||||
|
} else if (contact.blocking === true) {
|
||||||
|
result.blocking[contact.contact] = true;
|
||||||
|
} else if (contact.blocking === false) {
|
||||||
|
delete result.blocking[contact.contact];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
following[id] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contact(id, last_row_id, following, max_row_id) {
|
||||||
|
return await contacts_internal(id, last_row_id, following, max_row_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
|
||||||
|
let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id)));
|
||||||
|
let result = {};
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
let id = ids[i];
|
||||||
|
let contact = contacts[i];
|
||||||
|
let all_blocking = Object.assign({}, contact.blocking, blocking);
|
||||||
|
let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
|
||||||
|
let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
|
||||||
|
result[id] = [id, ...found, ...deeper];
|
||||||
|
}
|
||||||
|
return [...new Set(Object.values(result).flat())];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function following_deep(ids, depth, blocking) {
|
||||||
|
let db = await database('cache');
|
||||||
|
const k_cache_version = 5;
|
||||||
|
let cache = await db.get('following');
|
||||||
|
cache = cache ? JSON.parse(cache) : {};
|
||||||
|
if (cache.version !== k_cache_version) {
|
||||||
|
cache = {
|
||||||
|
version: k_cache_version,
|
||||||
|
following: {},
|
||||||
|
last_row_id: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let max_row_id = (await query(`
|
||||||
|
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||||
|
`, []))[0].max_row_id;
|
||||||
|
let result = await following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
|
||||||
|
cache.last_row_id = max_row_id;
|
||||||
|
let store = JSON.stringify(cache);
|
||||||
|
await db.set('following', store);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetch_about(db, ids, users) {
|
||||||
|
const k_cache_version = 1;
|
||||||
|
let cache = await db.get('about');
|
||||||
|
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 = 0;
|
||||||
|
await ssb.sqlAsync(`
|
||||||
|
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||||
|
`,
|
||||||
|
[],
|
||||||
|
function(row) {
|
||||||
|
max_row_id = row.max_row_id;
|
||||||
|
});
|
||||||
|
for (let id of Object.keys(cache.about)) {
|
||||||
|
if (ids.indexOf(id) == -1) {
|
||||||
|
delete cache.about[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let abouts = [];
|
||||||
|
await ssb.sqlAsync(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
messages.*
|
||||||
|
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.*
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.last_row_id = max_row_id;
|
||||||
|
await db.set('about', JSON.stringify(cache));
|
||||||
|
users = users || {};
|
||||||
|
for (let id of Object.keys(cache.about)) {
|
||||||
|
users[id] = Object.assign(users[id] || {}, cache.about[id]);
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
"SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||||
|
[id],
|
||||||
|
function (row) {
|
||||||
|
size += row.size;
|
||||||
|
});
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getSizes(ids) {
|
||||||
|
let sizes = {};
|
||||||
|
await ssb.sqlAsync(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
author,
|
||||||
|
(SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(messages.id))) AS size
|
||||||
|
FROM messages
|
||||||
|
JOIN json_each(?) AS ids ON author = ids.value
|
||||||
|
GROUP BY author
|
||||||
|
`,
|
||||||
|
[JSON.stringify(ids)],
|
||||||
|
function (row) {
|
||||||
|
sizes[row.author] = row.size;
|
||||||
|
});
|
||||||
|
return sizes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function niceSize(bytes) {
|
||||||
|
let value = bytes;
|
||||||
|
let unit = 'B';
|
||||||
|
const k_units = ['kB', 'MB', 'GB', 'TB'];
|
||||||
|
for (let u of k_units) {
|
||||||
|
if (value >= 1024) {
|
||||||
|
value /= 1024;
|
||||||
|
unit = u;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(value * 10) / 10 + ' ' + unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escape(value) {
|
||||||
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await app.setDocument('<pre style="color: #fff">building...</pre>');
|
||||||
|
let db = await database('ssb');
|
||||||
|
let whoami = await ssb.getIdentities();
|
||||||
|
let tree = '';
|
||||||
|
await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`);
|
||||||
|
let following = await following_deep(whoami, 2, {});
|
||||||
|
await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`);
|
||||||
|
let [about, sizes] = await Promise.all([
|
||||||
|
fetch_about(db, following, {}),
|
||||||
|
getSizes(following),
|
||||||
|
]);
|
||||||
|
await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`);
|
||||||
|
following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0)));
|
||||||
|
for (let id of following) {
|
||||||
|
tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`;
|
||||||
|
}
|
||||||
|
await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>');
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
4
apps/gg.json
Normal file
4
apps/gg.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "🗺"
|
||||||
|
}
|
80
apps/gg/app.js
Normal file
80
apps/gg/app.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
import * as strava from './strava.js';
|
||||||
|
|
||||||
|
let g_database;
|
||||||
|
let g_shared_database;
|
||||||
|
|
||||||
|
tfrpc.register(async function createIdentity() {
|
||||||
|
return ssb.createIdentity();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function appendMessage(id, message) {
|
||||||
|
print('APPEND', JSON.stringify(message));
|
||||||
|
return ssb.appendMessageWithIdentity(id, message);
|
||||||
|
});
|
||||||
|
tfrpc.register(function url() {
|
||||||
|
return core.url;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getUser() {
|
||||||
|
return core.user;
|
||||||
|
});
|
||||||
|
tfrpc.register(function getIdentities() {
|
||||||
|
return ssb.getIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseGet(key) {
|
||||||
|
return g_database ? g_database.get(key) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseSet(key, value) {
|
||||||
|
return g_database ? g_database.set(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseRemove(key, value) {
|
||||||
|
return g_database ? g_database.remove(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function sharedDatabaseGet(key) {
|
||||||
|
return g_shared_database ? g_shared_database.get(key) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function sharedDatabaseSet(key, value) {
|
||||||
|
return g_shared_database ? g_shared_database.set(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function sharedDatabaseRemove(key, value) {
|
||||||
|
return g_shared_database ? g_shared_database.remove(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_blob(blob) {
|
||||||
|
if (typeof(blob) == 'string') {
|
||||||
|
blob = utf8Encode(blob);
|
||||||
|
}
|
||||||
|
if (Array.isArray(blob)) {
|
||||||
|
blob = Uint8Array.from(blob);
|
||||||
|
}
|
||||||
|
return await ssb.blobStore(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
tfrpc.register(async function get_blob(id) {
|
||||||
|
return utf8Decode(await ssb.blobGet(id));
|
||||||
|
});
|
||||||
|
tfrpc.register(strava.refresh_token);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
g_shared_database = await shared_database('state');
|
||||||
|
if (core.user.credentials?.session?.name) {
|
||||||
|
g_database = await database('state');
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempt;
|
||||||
|
if (core.user.credentials?.session?.name) {
|
||||||
|
let shared_db = await shared_database('state');
|
||||||
|
attempt = await shared_db.get(core.user.credentials.session.name);
|
||||||
|
}
|
||||||
|
app.setDocument(utf8Decode(getFile('index.html')).replace('${data}', JSON.stringify({
|
||||||
|
attempt: attempt,
|
||||||
|
state: core.user?.credentials?.session?.name,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
81
apps/gg/gpx.js
Normal file
81
apps/gg/gpx.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
function xml_parse(xml) {
|
||||||
|
let result;
|
||||||
|
let path = [];
|
||||||
|
let tag_begin;
|
||||||
|
let text_begin;
|
||||||
|
for (let i = 0; i < xml.length; i++) {
|
||||||
|
let c = xml.charAt(i);
|
||||||
|
if (!tag_begin && c == '<') {
|
||||||
|
if (i > text_begin && path.length) {
|
||||||
|
let value = xml.substring(text_begin, i);
|
||||||
|
if (!/^\s*$/.test(value)) {
|
||||||
|
path[path.length - 1].value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_begin = i + 1;
|
||||||
|
} else if (tag_begin && c == '>') {
|
||||||
|
let tag = xml.substring(tag_begin, i).trim();
|
||||||
|
if (tag.startsWith('?') && tag.endsWith('?')) {
|
||||||
|
/* Ignore directives. */
|
||||||
|
} else if (tag.startsWith('/')) {
|
||||||
|
path.pop();
|
||||||
|
} else {
|
||||||
|
let parts = tag.split(' ');
|
||||||
|
let attributes = {};
|
||||||
|
for (let j = 1; j < parts.length; j++) {
|
||||||
|
let eq = parts[j].indexOf('=');
|
||||||
|
let value = parts[j].substring(eq + 1);
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.substring(1, value.length - 1);
|
||||||
|
}
|
||||||
|
attributes[parts[j].substring(0, eq)] = value;
|
||||||
|
}
|
||||||
|
let next = {name: parts[0], children: [], attributes: attributes};
|
||||||
|
if (path.length) {
|
||||||
|
path[path.length - 1].children.push(next);
|
||||||
|
} else {
|
||||||
|
result = next;
|
||||||
|
}
|
||||||
|
if (!tag.endsWith('/')) {
|
||||||
|
path.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_begin = undefined;
|
||||||
|
text_begin = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function* xml_each(node, name) {
|
||||||
|
for (let child of node.children) {
|
||||||
|
if (child.name == name) {
|
||||||
|
yield child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gpx_parse(xml) {
|
||||||
|
let result = {segments: []};
|
||||||
|
let tree = xml_parse(xml);
|
||||||
|
if (tree?.name == 'gpx') {
|
||||||
|
for (let trk of xml_each(tree, 'trk')) {
|
||||||
|
for (let trkseg of xml_each(trk, 'trkseg')) {
|
||||||
|
let segment = [];
|
||||||
|
for (let trkpt of xml_each(trkseg, 'trkpt')) {
|
||||||
|
segment.push({lat: parseFloat(trkpt.attributes.lat), lon: parseFloat(trkpt.attributes.lon)});
|
||||||
|
}
|
||||||
|
result.segments.push(segment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let metadata of xml_each(tree, 'metadata')) {
|
||||||
|
for (let link of xml_each(metadata, 'link')) {
|
||||||
|
result.link = link.attributes.href;
|
||||||
|
}
|
||||||
|
for (let time of xml_each(metadata, 'time')) {
|
||||||
|
result.time = time.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
21
apps/gg/handler.js
Normal file
21
apps/gg/handler.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as strava from './strava.js';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
print('handler running');
|
||||||
|
let r = await strava.authorization_code(request.query.code);
|
||||||
|
print('state =', request.query.state);
|
||||||
|
print('body = ', r.body);
|
||||||
|
if (request.query.state && r.body) {
|
||||||
|
let shared_db = await shared_database('state');
|
||||||
|
await shared_db.set(request.query.state, utf8Decode(r.body));
|
||||||
|
}
|
||||||
|
await respond({
|
||||||
|
data: r.body,
|
||||||
|
content_type: 'text/plain',
|
||||||
|
headers: {
|
||||||
|
Location: 'https://tildefriends.net/~cory/gg/',
|
||||||
|
},
|
||||||
|
status_code: 307,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
main();
|
14
apps/gg/index.html
Normal file
14
apps/gg/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="width: 100%; height: 100%; margin: 0; padding: 0">
|
||||||
|
<head>
|
||||||
|
<script>window.litDisableBundleWarning = true;</script>
|
||||||
|
<script>
|
||||||
|
let g_data = ${data};
|
||||||
|
</script>
|
||||||
|
<script src="script.js" type="module"></script>
|
||||||
|
<script src="leaflet.js"></script>
|
||||||
|
</head>
|
||||||
|
<body style="color: #fff; display: flex; flex-flow: column; height: 100%; width: 100%; margin: 0; padding: 0">
|
||||||
|
<gg-app style="width: 100%; height: 100%" id="ggapp"></gg-app>
|
||||||
|
</body>
|
||||||
|
</html>
|
661
apps/gg/leaflet.css
Normal file
661
apps/gg/leaflet.css
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
6
apps/gg/leaflet.js
Normal file
6
apps/gg/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
1
apps/gg/leaflet.js.map
Normal file
1
apps/gg/leaflet.js.map
Normal file
File diff suppressed because one or more lines are too long
126
apps/gg/lit-all.min.js
vendored
Normal file
126
apps/gg/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/gg/lit-all.min.js.map
Normal file
1
apps/gg/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
158
apps/gg/polyline.js
Normal file
158
apps/gg/polyline.js
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Based off of [the offical Google document](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
|
||||||
|
*
|
||||||
|
* Some parts from [this implementation](http://facstaff.unca.edu/mcmcclur/GoogleMaps/EncodePolyline/PolylineEncoder.js)
|
||||||
|
* by [Mark McClure](http://facstaff.unca.edu/mcmcclur/)
|
||||||
|
*
|
||||||
|
* @module polyline
|
||||||
|
*/
|
||||||
|
|
||||||
|
var polyline = {};
|
||||||
|
|
||||||
|
function py2_round(value) {
|
||||||
|
// Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values
|
||||||
|
return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encode(current, previous, factor) {
|
||||||
|
current = py2_round(current * factor);
|
||||||
|
previous = py2_round(previous * factor);
|
||||||
|
var coordinate = (current - previous) * 2;
|
||||||
|
if (coordinate < 0) {
|
||||||
|
coordinate = -coordinate - 1
|
||||||
|
}
|
||||||
|
var output = '';
|
||||||
|
while (coordinate >= 0x20) {
|
||||||
|
output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63);
|
||||||
|
coordinate /= 32;
|
||||||
|
}
|
||||||
|
output += String.fromCharCode((coordinate | 0) + 63);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes to a [latitude, longitude] coordinates array.
|
||||||
|
*
|
||||||
|
* This is adapted from the implementation in Project-OSRM.
|
||||||
|
*
|
||||||
|
* @param {String} str
|
||||||
|
* @param {Number} precision
|
||||||
|
* @returns {Array}
|
||||||
|
*
|
||||||
|
* @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js
|
||||||
|
*/
|
||||||
|
polyline.decode = function(str, precision) {
|
||||||
|
var index = 0,
|
||||||
|
lat = 0,
|
||||||
|
lng = 0,
|
||||||
|
coordinates = [],
|
||||||
|
shift = 0,
|
||||||
|
result = 0,
|
||||||
|
byte = null,
|
||||||
|
latitude_change,
|
||||||
|
longitude_change,
|
||||||
|
factor = Math.pow(10, Number.isInteger(precision) ? precision : 5);
|
||||||
|
|
||||||
|
// Coordinates have variable length when encoded, so just keep
|
||||||
|
// track of whether we've hit the end of the string. In each
|
||||||
|
// loop iteration, a single coordinate is decoded.
|
||||||
|
while (index < str.length) {
|
||||||
|
|
||||||
|
// Reset shift, result, and byte
|
||||||
|
byte = null;
|
||||||
|
shift = 1;
|
||||||
|
result = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
byte = str.charCodeAt(index++) - 63;
|
||||||
|
result += (byte & 0x1f) * shift;
|
||||||
|
shift *= 32;
|
||||||
|
} while (byte >= 0x20);
|
||||||
|
|
||||||
|
latitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||||
|
|
||||||
|
shift = 1;
|
||||||
|
result = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
byte = str.charCodeAt(index++) - 63;
|
||||||
|
result += (byte & 0x1f) * shift;
|
||||||
|
shift *= 32;
|
||||||
|
} while (byte >= 0x20);
|
||||||
|
|
||||||
|
longitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||||
|
|
||||||
|
lat += latitude_change;
|
||||||
|
lng += longitude_change;
|
||||||
|
|
||||||
|
coordinates.push([lat / factor, lng / factor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return coordinates;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the given [latitude, longitude] coordinates array.
|
||||||
|
*
|
||||||
|
* @param {Array.<Array.<Number>>} coordinates
|
||||||
|
* @param {Number} precision
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
polyline.encode = function(coordinates, precision) {
|
||||||
|
if (!coordinates.length) { return ''; }
|
||||||
|
|
||||||
|
var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5),
|
||||||
|
output = encode(coordinates[0][0], 0, factor) + encode(coordinates[0][1], 0, factor);
|
||||||
|
|
||||||
|
for (var i = 1; i < coordinates.length; i++) {
|
||||||
|
var a = coordinates[i], b = coordinates[i - 1];
|
||||||
|
output += encode(a[0], b[0], factor);
|
||||||
|
output += encode(a[1], b[1], factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
function flipped(coords) {
|
||||||
|
var flipped = [];
|
||||||
|
for (var i = 0; i < coords.length; i++) {
|
||||||
|
var coord = coords[i].slice();
|
||||||
|
flipped.push([coord[1], coord[0]]);
|
||||||
|
}
|
||||||
|
return flipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes a GeoJSON LineString feature/geometry.
|
||||||
|
*
|
||||||
|
* @param {Object} geojson
|
||||||
|
* @param {Number} precision
|
||||||
|
* @returns {String}
|
||||||
|
*/
|
||||||
|
polyline.fromGeoJSON = function(geojson, precision) {
|
||||||
|
if (geojson && geojson.type === 'Feature') {
|
||||||
|
geojson = geojson.geometry;
|
||||||
|
}
|
||||||
|
if (!geojson || geojson.type !== 'LineString') {
|
||||||
|
throw new Error('Input must be a GeoJSON LineString');
|
||||||
|
}
|
||||||
|
return polyline.encode(flipped(geojson.coordinates), precision);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes to a GeoJSON LineString geometry.
|
||||||
|
*
|
||||||
|
* @param {String} str
|
||||||
|
* @param {Number} precision
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
polyline.toGeoJSON = function(str, precision) {
|
||||||
|
var coords = polyline.decode(str, precision);
|
||||||
|
return {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: flipped(coords)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let polyline_decode = polyline.decode;
|
||||||
|
export { polyline_decode as decode };
|
789
apps/gg/script.js
Normal file
789
apps/gg/script.js
Normal file
@ -0,0 +1,789 @@
|
|||||||
|
import {LitElement, html, unsafeHTML, css, guard, until} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import * as polyline from './polyline.js';
|
||||||
|
import {gpx_parse} from './gpx.js';
|
||||||
|
|
||||||
|
const k_client_id = '28276';
|
||||||
|
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||||
|
|
||||||
|
const k_color_snow = [128, 128, 255, 255];
|
||||||
|
const k_color_ice = [160, 160, 255, 255];
|
||||||
|
const k_color_water = [0, 0, 255, 255];
|
||||||
|
const k_color_dirt = [128, 129, 130, 255];
|
||||||
|
const k_color_pavement = [32, 32, 32, 255];
|
||||||
|
const k_color_grass = [0, 255, 0, 255];
|
||||||
|
const k_color_default = [128, 128, 128, 255];
|
||||||
|
|
||||||
|
const k_store = {
|
||||||
|
'🦞': 15,
|
||||||
|
'🛶': 10,
|
||||||
|
'🏠': 10,
|
||||||
|
'⛰': 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
const k_marker_snap = {x: 5, y: 1};
|
||||||
|
|
||||||
|
class GgAppElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
user: {type: Object},
|
||||||
|
strava: {type: Object},
|
||||||
|
activities: {type: Array},
|
||||||
|
activity: {type: Object},
|
||||||
|
world: {type: Object},
|
||||||
|
whoami: {type: String},
|
||||||
|
status: {type: Object},
|
||||||
|
tab: {type: String},
|
||||||
|
url: {type: String},
|
||||||
|
currency: {type: Number},
|
||||||
|
to_build: {type: String},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.activities = [];
|
||||||
|
this.activity = {};
|
||||||
|
this.loaded_activities = [];
|
||||||
|
this.placed_emojis = [];
|
||||||
|
this.strava = {};
|
||||||
|
this.min_lat = Number.MAX_VALUE;
|
||||||
|
this.min_lon = Number.MAX_VALUE;
|
||||||
|
this.max_lat = -Number.MAX_VALUE;
|
||||||
|
this.max_lon = -Number.MAX_VALUE;
|
||||||
|
this.focus = undefined;
|
||||||
|
this.status = undefined;
|
||||||
|
this.tab = 'map';
|
||||||
|
this.load().catch(function(e) {
|
||||||
|
console.log('load error', e);
|
||||||
|
});
|
||||||
|
this.to_build = '🏠';
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
console.log('load');
|
||||||
|
this.user = await tfrpc.rpc.getUser();
|
||||||
|
this.url = (await tfrpc.rpc.url()).split('?')[0];
|
||||||
|
try {
|
||||||
|
await this.update_credentials();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('update_credentials failed', e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.update_activities();
|
||||||
|
} catch (e) {
|
||||||
|
console.log('update_activities failed', e);
|
||||||
|
}
|
||||||
|
await this.acquire_ssb_identity();
|
||||||
|
if (this.whoami && this.activities?.length) {
|
||||||
|
await this.sync_activities();
|
||||||
|
}
|
||||||
|
await this.get_activities_from_ssb();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* https://gist.github.com/jcouyang/632709f30e12a7879a73e9e132c0d56b?permalink_comment_id=3591045#gistcomment-3591045 */
|
||||||
|
async promise_all(promises, max_concurrent) {
|
||||||
|
let index = 0;
|
||||||
|
let results = [];
|
||||||
|
async function exec_thread() {
|
||||||
|
while (index < promises.length) {
|
||||||
|
const current = index++;
|
||||||
|
results[current] = await promises[current];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const threads = [];
|
||||||
|
for (let thread = 0; thread < max_concurrent; thread++) {
|
||||||
|
threads.push(exec_thread());
|
||||||
|
}
|
||||||
|
await Promise.all(threads);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_activities_from_ssb() {
|
||||||
|
this.status = {text: 'loading activities'};
|
||||||
|
this.loaded_activities = [];
|
||||||
|
let rows = await tfrpc.rpc.query(`
|
||||||
|
SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id
|
||||||
|
FROM messages_fts('"gg-activity"')
|
||||||
|
JOIN messages ON messages.rowid = messages_fts.rowid,
|
||||||
|
json_each(messages.content, '$.mentions') as mention
|
||||||
|
WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||||
|
json_extract(mention.value, '$.name') = 'activity_data'
|
||||||
|
ORDER BY messages.timestamp DESC
|
||||||
|
`, []);
|
||||||
|
this.status = {text: 'loading activity data'};
|
||||||
|
let authors = rows.map(x => x.author);
|
||||||
|
let blobs = await this.promise_all(rows.map(x => tfrpc.rpc.get_blob(x.blob_id)), 8);
|
||||||
|
this.status = {text: 'processing activity data'};
|
||||||
|
for (let [index, blob] of blobs.entries()) {
|
||||||
|
let activity;
|
||||||
|
try {
|
||||||
|
activity = JSON.parse(blob);
|
||||||
|
} catch {
|
||||||
|
activity = gpx_parse(blob);
|
||||||
|
}
|
||||||
|
if (activity) {
|
||||||
|
activity.author = authors[index];
|
||||||
|
this.loaded_activities.push(activity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.status = {text: 'calculating balance'};
|
||||||
|
rows = await tfrpc.rpc.query(`
|
||||||
|
SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity'
|
||||||
|
`, [this.whoami]);
|
||||||
|
let currency = rows[0].currency;
|
||||||
|
rows = await tfrpc.rpc.query(`
|
||||||
|
SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place'
|
||||||
|
`, [this.whoami]);
|
||||||
|
let spent = rows[0].cost;
|
||||||
|
this.currency = currency - spent;
|
||||||
|
this.status = {text: 'getting placed emojis'};
|
||||||
|
rows = await tfrpc.rpc.query(`
|
||||||
|
SELECT messages.content
|
||||||
|
FROM messages_fts('"gg-place"')
|
||||||
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||||
|
WHERE json_extract(messages.content, '$.type') = 'gg-place'
|
||||||
|
ORDER BY messages.timestamp
|
||||||
|
`);
|
||||||
|
for (let row of rows) {
|
||||||
|
console.log(row.content);
|
||||||
|
let content = JSON.parse(row.content);
|
||||||
|
this.placed_emojis.push({
|
||||||
|
position: content.position,
|
||||||
|
emoji: content.emoji,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(this.placed_emojis);
|
||||||
|
this.status = undefined;
|
||||||
|
this.update_map();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync_activities() {
|
||||||
|
let ids = this.activities.map(x => `https://www.strava.com/activities/${x.id}`);
|
||||||
|
let missing = await tfrpc.rpc.query(`
|
||||||
|
WITH my_activities AS (
|
||||||
|
SELECT json_extract(mention.value, '$.link') AS url
|
||||||
|
FROM messages, json_each(messages.content, '$.mentions') AS mention
|
||||||
|
WHERE
|
||||||
|
author = ? AND
|
||||||
|
json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||||
|
json_extract(mention.value, '$.name') = 'activity_url')
|
||||||
|
SELECT from_strava.value FROM json_each(?) AS from_strava
|
||||||
|
LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url
|
||||||
|
WHERE my_activities.url IS NULL
|
||||||
|
`, [this.whoami, JSON.stringify(ids)]);
|
||||||
|
console.log('missing = ', missing);
|
||||||
|
for (let [index, row] of missing.entries()) {
|
||||||
|
this.status = {text: 'syncing from strava', value: index, max: missing.length};
|
||||||
|
let url = row.value;
|
||||||
|
let id = url.match(/.*\/(\d+)/)[1];
|
||||||
|
let response = await fetch(`https://www.strava.com/api/v3/activities/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let activity = await response.json();
|
||||||
|
let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity));
|
||||||
|
let message = {
|
||||||
|
type: 'gg-activity',
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
link: url,
|
||||||
|
name: 'activity_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: blob_id,
|
||||||
|
name: 'activity_data',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||||
|
}
|
||||||
|
this.status = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire_ssb_identity() {
|
||||||
|
let user = await tfrpc.rpc.getUser();
|
||||||
|
if (!user?.credentials?.session?.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ids = await tfrpc.rpc.getIdentities();
|
||||||
|
let players = ids.length ? (await tfrpc.rpc.query(`
|
||||||
|
SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value
|
||||||
|
WHERE
|
||||||
|
json_extract(messages.content, '$.type') = 'gg-player' AND
|
||||||
|
json_extract(messages.content, '$.active')
|
||||||
|
ORDER BY timestamp DESC limit 1
|
||||||
|
`, [JSON.stringify(ids)])).map(row => row.author) : [];
|
||||||
|
if (!players.length) {
|
||||||
|
this.whoami = await tfrpc.rpc.createIdentity();
|
||||||
|
if (this.whoami) {
|
||||||
|
await tfrpc.rpc.appendMessage(this.whoami, {
|
||||||
|
type: 'gg-player',
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
players.sort();
|
||||||
|
this.whoami = players[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_credentials() {
|
||||||
|
let name = this.user?.credentials?.session?.name;
|
||||||
|
if (!name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let shared = await tfrpc.rpc.sharedDatabaseGet(name);
|
||||||
|
if (shared) {
|
||||||
|
await tfrpc.rpc.databaseSet('strava', shared);
|
||||||
|
await tfrpc.rpc.sharedDatabaseRemove(name);
|
||||||
|
}
|
||||||
|
this.strava = JSON.parse(await tfrpc.rpc.databaseGet('strava') || '{}');
|
||||||
|
if (new Date().valueOf() / 1000 > this.strava.expires_at) {
|
||||||
|
console.log('this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at);
|
||||||
|
let x = await tfrpc.rpc.refresh_token(this.strava);
|
||||||
|
if (x) {
|
||||||
|
this.strava = x;
|
||||||
|
await tfrpc.rpc.databaseSet('strava', JSON.stringify(x));
|
||||||
|
} else {
|
||||||
|
this.strava = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_activities() {
|
||||||
|
if (this?.strava?.access_token) {
|
||||||
|
let response = await fetch('https://www.strava.com/api/v3/athlete/activities', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.activities = await response.json();
|
||||||
|
this.activities.sort((a, b) => (a.id - b.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
color_to_emoji(color) {
|
||||||
|
const k_map = [
|
||||||
|
[k_color_snow, '⬜'],
|
||||||
|
[k_color_ice, '🟦'],
|
||||||
|
[k_color_water, '🟦'],
|
||||||
|
[k_color_dirt, '🟫'],
|
||||||
|
[k_color_pavement, '⬛'],
|
||||||
|
[k_color_grass, '🟩'],
|
||||||
|
[k_color_default, '🟧'],
|
||||||
|
];
|
||||||
|
for (let m of k_map) {
|
||||||
|
if (m[0][0] == color[0] &&
|
||||||
|
m[0][1] == color[1] &&
|
||||||
|
m[0][2] == color[2] &&
|
||||||
|
m[0][3] == color[3]) {
|
||||||
|
return m[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activity_bounds(activity) {
|
||||||
|
let min_lat = Number.MAX_VALUE;
|
||||||
|
let min_lon = Number.MAX_VALUE;
|
||||||
|
let max_lat = -Number.MAX_VALUE;
|
||||||
|
let max_lon = -Number.MAX_VALUE;
|
||||||
|
if (activity?.map?.polyline) {
|
||||||
|
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||||
|
min_lat = Math.min(min_lat, pt[0]);
|
||||||
|
min_lon = Math.min(min_lon, pt[1]);
|
||||||
|
max_lat = Math.max(max_lat, pt[0]);
|
||||||
|
max_lon = Math.max(max_lon, pt[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activity?.segments) {
|
||||||
|
for (let segment of activity.segments) {
|
||||||
|
for (let pt of segment) {
|
||||||
|
min_lat = Math.min(min_lat, pt.lat);
|
||||||
|
min_lon = Math.min(min_lon, pt.lon);
|
||||||
|
max_lat = Math.max(max_lat, pt.lat);
|
||||||
|
max_lon = Math.max(max_lon, pt.lon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
min: {
|
||||||
|
lat: min_lat,
|
||||||
|
lng: min_lon,
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
lat: max_lat,
|
||||||
|
lng: max_lon,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
on_click(event) {
|
||||||
|
let popup = L.popup()
|
||||||
|
.setLatLng(event.latlng)
|
||||||
|
.setContent(`
|
||||||
|
<div><a target="_top" href="https://www.google.com/maps/search/?api=1&query=${event.latlng.lat},${event.latlng.lng}">${event.latlng.lat}, ${event.latlng.lng}</a></div>
|
||||||
|
`)
|
||||||
|
.openOn(this.leaflet);
|
||||||
|
}
|
||||||
|
|
||||||
|
async build() {
|
||||||
|
if (this.popup) {
|
||||||
|
this.popup.remove();
|
||||||
|
}
|
||||||
|
if (!this.marker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let latlng = this.marker.getLatLng();
|
||||||
|
|
||||||
|
let cost = k_store[this.to_build];
|
||||||
|
if (cost > this.currency) {
|
||||||
|
alert('Insufficient funds.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let message = {
|
||||||
|
type: 'gg-place',
|
||||||
|
position: {lat: latlng.lat, lng: latlng.lng},
|
||||||
|
emoji: this.to_build,
|
||||||
|
cost: cost,
|
||||||
|
};
|
||||||
|
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||||
|
this.marker.remove();
|
||||||
|
this.placed_emojis.push({
|
||||||
|
position: {lat: latlng.lat, lng: latlng.lng},
|
||||||
|
emoji: this.to_build,
|
||||||
|
});
|
||||||
|
this.currency -= cost;
|
||||||
|
return this.update_map();
|
||||||
|
}
|
||||||
|
|
||||||
|
on_marker_click(event) {
|
||||||
|
this.popup = L.popup()
|
||||||
|
.setLatLng(event.latlng)
|
||||||
|
.setContent(`
|
||||||
|
${this.to_build} (-${k_store[this.to_build]}) <input type="button" value="Build" onclick="document.getElementById('ggapp').build()"></input>
|
||||||
|
`)
|
||||||
|
.openOn(this.leaflet);
|
||||||
|
}
|
||||||
|
|
||||||
|
snap_to_grid(latlng, fudge) {
|
||||||
|
let position = this.leaflet.options.crs.latLngToPoint(latlng, this.leaflet.getZoom());
|
||||||
|
position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0);
|
||||||
|
position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0);
|
||||||
|
position = this.leaflet.options.crs.pointToLatLng(position, this.leaflet.getZoom());
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
on_marker_move(event) {
|
||||||
|
if (!this.no_snap && this.marker) {
|
||||||
|
this.no_snap = true;
|
||||||
|
this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap));
|
||||||
|
this.no_snap = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on_mouse_down(event) {
|
||||||
|
if (this.marker) {
|
||||||
|
this.marker.remove();
|
||||||
|
this.marker = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.to_build) {
|
||||||
|
this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), {icon: L.divIcon({className: 'build-icon'}), draggable: true}).addTo(this.leaflet);
|
||||||
|
this.marker.on({click: this.on_marker_click.bind(this)});
|
||||||
|
this.marker.on({drag: this.on_marker_move.bind(this)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_map() {
|
||||||
|
let map = this.shadowRoot.getElementById('map');
|
||||||
|
if (!map || !this.loaded_activities.length) {
|
||||||
|
this.leaflet = undefined;
|
||||||
|
this.grid_layer = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.leaflet) {
|
||||||
|
this.leaflet = L.map(map, {attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false});
|
||||||
|
this.leaflet.on({contextmenu: this.on_click.bind(this)});
|
||||||
|
this.leaflet.on({click: this.on_mouse_down.bind(this)});
|
||||||
|
}
|
||||||
|
let self = this;
|
||||||
|
let grid_layer = L.GridLayer.extend({
|
||||||
|
createTile: function(coords) {
|
||||||
|
var tile = L.DomUtil.create('canvas', 'leaflet-tile');
|
||||||
|
var size = this.getTileSize();
|
||||||
|
tile.width = size.x;
|
||||||
|
tile.height = size.y;
|
||||||
|
var context = tile.getContext('2d');
|
||||||
|
context.font = '10pt sans';
|
||||||
|
let bounds = this._tileCoordsToBounds(coords);
|
||||||
|
let degrees = 360.0 / (2 ** coords.z);
|
||||||
|
let ul = bounds.getNorthWest();
|
||||||
|
let lr = bounds.getSouthEast();
|
||||||
|
|
||||||
|
let mini = document.createElement('canvas');
|
||||||
|
mini.width = Math.floor(size.x / 16.0);
|
||||||
|
mini.height = Math.floor(size.y / 16.0);
|
||||||
|
let mini_context = mini.getContext('2d');
|
||||||
|
let image_data = context.getImageData(0, 0, mini.width, mini.height);
|
||||||
|
for (let activity of self.loaded_activities) {
|
||||||
|
self.draw_activity_to_tile(image_data, mini.width, mini.height, ul, lr, activity);
|
||||||
|
}
|
||||||
|
context.textAlign = 'left';
|
||||||
|
context.textBaseline = 'top';
|
||||||
|
for (let x = 0; x < mini.width; x++) {
|
||||||
|
for (let y = 0; y < mini.height; y++) {
|
||||||
|
let start = (y * mini.width + x) * 4;
|
||||||
|
let pixel = self.color_to_emoji(image_data.data.slice(start, start + 4));
|
||||||
|
if (pixel) {
|
||||||
|
context.fillText(pixel, x * size.x / mini.width, y * size.y / mini.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let placed of self.placed_emojis) {
|
||||||
|
let position = self.leaflet.options.crs.latLngToPoint(self.snap_to_grid(placed.position), coords.z);
|
||||||
|
let tile_x = Math.floor(position.x / size.x);
|
||||||
|
let tile_y = Math.floor(position.y / size.y);
|
||||||
|
position.x = position.x - tile_x * size.x;
|
||||||
|
position.y = position.y - tile_y * size.y;
|
||||||
|
if (tile_x == coords.x && tile_y == coords.y) {
|
||||||
|
context.fillText(placed.emoji, position.x, position.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tile;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (this.grid_layer) {
|
||||||
|
this.grid_layer.redraw();
|
||||||
|
} else {
|
||||||
|
this.grid_layer = new grid_layer();
|
||||||
|
this.grid_layer.addTo(this.leaflet);
|
||||||
|
}
|
||||||
|
for (let activity of this.loaded_activities) {
|
||||||
|
let bounds = this.activity_bounds(activity);
|
||||||
|
this.min_lat = Math.min(this.min_lat, bounds.min.lat);
|
||||||
|
this.min_lon = Math.min(this.min_lon, bounds.min.lng);
|
||||||
|
this.max_lat = Math.max(this.max_lat, bounds.max.lat);
|
||||||
|
this.max_lon = Math.max(this.max_lon, bounds.max.lng);
|
||||||
|
}
|
||||||
|
if (this.focus) {
|
||||||
|
this.leaflet.fitBounds([
|
||||||
|
this.focus.min,
|
||||||
|
this.focus.max,
|
||||||
|
]);
|
||||||
|
this.focus = undefined;
|
||||||
|
} else {
|
||||||
|
this.leaflet.fitBounds([
|
||||||
|
[this.min_lat, this.min_lon],
|
||||||
|
[this.max_lat, this.max_lon],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activity_to_color(activity) {
|
||||||
|
let color = [0, 0, 0, 255];
|
||||||
|
switch (activity.sport_type) {
|
||||||
|
/* Implies snow. */
|
||||||
|
case 'AlpineSki':
|
||||||
|
case 'BackcountrySki':
|
||||||
|
case 'NordicSki':
|
||||||
|
case 'Snowshoe':
|
||||||
|
case 'Snowboard':
|
||||||
|
color = k_color_snow;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Implies ice. */
|
||||||
|
case 'IceSkate':
|
||||||
|
case 'InlineSkate':
|
||||||
|
color = k_color_ice;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Implies water. */
|
||||||
|
case 'Canoeing':
|
||||||
|
case 'Kayaking':
|
||||||
|
case 'Kitesurf':
|
||||||
|
case 'Rowing':
|
||||||
|
case 'Sail':
|
||||||
|
case 'StandUpPaddling':
|
||||||
|
case 'Surfing':
|
||||||
|
case 'Swim':
|
||||||
|
case 'Windsurf':
|
||||||
|
color = k_color_water;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Implies dirt. */
|
||||||
|
case 'EMountainBikeRide':
|
||||||
|
case 'Hike':
|
||||||
|
case 'MountainBikeRide':
|
||||||
|
case 'RockClimbing':
|
||||||
|
case 'TrailRun':
|
||||||
|
color = k_color_dirt;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Implies pavement. */
|
||||||
|
case 'EBikeRide':
|
||||||
|
case 'GravelRide':
|
||||||
|
case 'Handcycle':
|
||||||
|
case 'Ride':
|
||||||
|
case 'RollerSki':
|
||||||
|
case 'Run':
|
||||||
|
case 'Skateboard':
|
||||||
|
case 'Badminton':
|
||||||
|
case 'Tennis':
|
||||||
|
case 'Velomobile':
|
||||||
|
case 'Walk':
|
||||||
|
case 'Wheelchair':
|
||||||
|
color = k_color_pavement;
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* Grass, maybe? */
|
||||||
|
case 'Golf':
|
||||||
|
case 'Soccer':
|
||||||
|
case 'Squash':
|
||||||
|
color = k_color_grass;
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Crossfit,
|
||||||
|
// Elliptical
|
||||||
|
// HighIntensityIntervalTraining
|
||||||
|
// Pickleball
|
||||||
|
// Pilates
|
||||||
|
// Racquetball
|
||||||
|
// StairStepper
|
||||||
|
// TableTennis,
|
||||||
|
// VirtualRide
|
||||||
|
// VirtualRow
|
||||||
|
// VirtualRun
|
||||||
|
// WeightTraining
|
||||||
|
// Workout
|
||||||
|
// Yoga
|
||||||
|
default:
|
||||||
|
color = k_color_default;
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
line(image_data, x0, y0, x1, y1, value) {
|
||||||
|
/* <3 https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm */
|
||||||
|
let dx = Math.abs(x1 - x0);
|
||||||
|
let sx = x0 < x1 ? 1 : -1;
|
||||||
|
let dy = -Math.abs(y1 - y0);
|
||||||
|
let sy = y0 < y1 ? 1 : -1;
|
||||||
|
let error = dx + dy;
|
||||||
|
while (true) {
|
||||||
|
if (x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height) {
|
||||||
|
let base = (y0 * image_data.width + x0) * 4;
|
||||||
|
image_data.data[base + 0] = value[0];
|
||||||
|
image_data.data[base + 1] = value[1];
|
||||||
|
image_data.data[base + 2] = value[2];
|
||||||
|
image_data.data[base + 3] = value[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x0 == x1 && y0 == y1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let e2 = 2 * error;
|
||||||
|
if (e2 >= dy) {
|
||||||
|
if (x0 == x1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
error += dy;
|
||||||
|
x0 = Math.round(x0 + sx);
|
||||||
|
}
|
||||||
|
if (e2 <= dx) {
|
||||||
|
if (y0 == y1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
error += dx;
|
||||||
|
y0 = Math.round(y0 + sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw_activity_to_tile(image_data, width, height, ul, lr, activity) {
|
||||||
|
let color = this.activity_to_color(activity);
|
||||||
|
if (activity?.map?.polyline) {
|
||||||
|
let last;
|
||||||
|
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||||
|
let px = [
|
||||||
|
Math.floor(width * (pt[1] - ul.lng) / (lr.lng - ul.lng)),
|
||||||
|
Math.floor(height * (pt[0] - ul.lat) / (lr.lat - ul.lat)),
|
||||||
|
];
|
||||||
|
if (last) {
|
||||||
|
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||||
|
}
|
||||||
|
last = px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activity?.segments) {
|
||||||
|
for (let segment of activity.segments) {
|
||||||
|
let last;
|
||||||
|
for (let pt of segment) {
|
||||||
|
let px = [
|
||||||
|
Math.floor(width * (pt.lon - ul.lng) / (lr.lng - ul.lng)),
|
||||||
|
Math.floor(height * (pt.lat - ul.lat) / (lr.lat - ul.lat)),
|
||||||
|
];
|
||||||
|
if (last) {
|
||||||
|
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||||
|
}
|
||||||
|
last = px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async on_upload(event) {
|
||||||
|
try {
|
||||||
|
let file = event.srcElement.files[0];
|
||||||
|
let xml = await file.text();
|
||||||
|
let gpx = gpx_parse(xml);
|
||||||
|
let blob_id = await tfrpc.rpc.store_blob(xml);
|
||||||
|
console.log('blob_id = ', blob_id);
|
||||||
|
console.log(gpx);
|
||||||
|
let message = {
|
||||||
|
type: 'gg-activity',
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
link: `https://${gpx.link}/activity/${gpx.time}`,
|
||||||
|
name: 'activity_url',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
link: blob_id,
|
||||||
|
name: 'activity_data',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
console.log('id =', this.whoami, 'message = ', message);
|
||||||
|
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||||
|
console.log('appended message', id);
|
||||||
|
alert('Activity uploaded.');
|
||||||
|
await this.get_activities_from_ssb();
|
||||||
|
} catch (e) {
|
||||||
|
alert(`Error: ${JSON.stringify(e, null, 2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upload() {
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.onchange = (event) => this.on_upload(event);
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.update_map();
|
||||||
|
}
|
||||||
|
|
||||||
|
focus_map(activity) {
|
||||||
|
let bounds = this.activity_bounds(activity);
|
||||||
|
if (bounds.min.lat < bounds.max.lat &&
|
||||||
|
bounds.min.lng < bounds.max.lng) {
|
||||||
|
this.tab = 'map';
|
||||||
|
this.focus = bounds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_news() {
|
||||||
|
return html`
|
||||||
|
<ul>
|
||||||
|
${this.loaded_activities.map(x => html`
|
||||||
|
<li style="cursor: pointer" @click=${() => this.focus_map(x)}>${x.author} ${x.name ?? x.time}</li>
|
||||||
|
`)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_store_item(item) {
|
||||||
|
let [emoji, cost] = item;
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<input type="button" value="${emoji}" @click=${() => this.to_build = emoji}></input> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_store() {
|
||||||
|
return html`
|
||||||
|
<h2>Store</h2>
|
||||||
|
<div><b>Your balance:</b> ${this.currency}</div>
|
||||||
|
${Object.entries(k_store).map(this.render_store_item.bind(this))}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let header;
|
||||||
|
if (!this.user?.credentials?.session?.name) {
|
||||||
|
header = html`<div style="flex: 1 0">Please <a target="_top" href="/login?return=${this.url}">login</a> to Tilde Friends, first.</div>`;
|
||||||
|
} else if (!this.strava?.access_token) {
|
||||||
|
let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`;
|
||||||
|
header = html`
|
||||||
|
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||||
|
<div style="flex: 1 1">Please <a target="_top" href=${strava_url}>login</a> to Strava.</div>
|
||||||
|
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
|
||||||
|
<input type="button" value="📁" @click=${this.upload}></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
header = html`
|
||||||
|
<div>
|
||||||
|
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||||
|
<h1>Welcome, ${this.user.credentials.session.name}</h1>
|
||||||
|
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
|
||||||
|
<input type="button" value="📁" @click=${this.upload}></input>
|
||||||
|
</div>
|
||||||
|
<h3 ?hidden=${!this.status?.text}>${this.status?.text} <progress ?hidden=${!this.status?.max} value=${this.status?.value} max=${this.status?.max}>${this.status?.value}</progress></h3>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let navigation = html`
|
||||||
|
<style>
|
||||||
|
#navigation input[type="button"] {
|
||||||
|
min-width: 3em;
|
||||||
|
min-height: 3em;
|
||||||
|
flex: 1 0;
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div id="navigation" style="display: flex; flex-direction: row">
|
||||||
|
<input type="button" id="button_map" @click=${() => this.tab = 'map'} value="🗺️Map"></input>
|
||||||
|
<input type="button" id="button_news" @click=${() => this.tab = 'news'} value="🏃News"></input>
|
||||||
|
<input type="button" id="button_friends" @click=${() => this.tab = 'friends'} value="👫Friends"></input>
|
||||||
|
<input type="button" id="button_store" @click=${() => this.tab = 'store'} value="🏗️Store"></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let content;
|
||||||
|
switch (this.tab) {
|
||||||
|
case 'map':
|
||||||
|
content = html`<div id="map" style="width: 100%; height: 100%"></div>`;
|
||||||
|
break;
|
||||||
|
case 'news':
|
||||||
|
content = this.render_news();
|
||||||
|
break;
|
||||||
|
case 'friends':
|
||||||
|
content = html`<div>Friends</div>`;
|
||||||
|
break;
|
||||||
|
case 'store':
|
||||||
|
content = this.render_store();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
.build-icon::before {
|
||||||
|
content: '📍';
|
||||||
|
border: 2px solid red;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="leaflet.css"/>
|
||||||
|
<div style="width: 100%; height: 100%; display: flex; flex-direction: column">
|
||||||
|
${header}
|
||||||
|
<div style="flex: 1 0; overflow: scroll">${content}</div>
|
||||||
|
${navigation}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('gg-app', GgAppElement);
|
20
apps/gg/strava.js
Normal file
20
apps/gg/strava.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const k_client_id = '28276';
|
||||||
|
const k_client_secret = '3123f1f5afe132d9731111066d1d17bdb22ef27e';
|
||||||
|
const k_access_token = 'f753e77764c26252bd2d80e7c5cc17ace51a8864';
|
||||||
|
const k_refresh_token = 'f58d8e1b5a3ec3bf96e681589d5014f9a294f5a4';
|
||||||
|
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||||
|
|
||||||
|
export async function refresh_token(token) {
|
||||||
|
let r = await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&refresh_token=${token.refresh_token}&grant_type=refresh_token`,
|
||||||
|
});
|
||||||
|
return r?.body ? JSON.parse(utf8Decode(r.body)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorization_code(code) {
|
||||||
|
return await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&code=${code}&grant_type=authorization_code`,
|
||||||
|
});
|
||||||
|
}
|
4
apps/issues.json
Normal file
4
apps/issues.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "🦟"
|
||||||
|
}
|
105
apps/issues/app.js
Normal file
105
apps/issues/app.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
|
||||||
|
let g_database;
|
||||||
|
let g_hash;
|
||||||
|
|
||||||
|
tfrpc.register(async function localStorageGet(key) {
|
||||||
|
return app.localStorageGet(key);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function localStorageSet(key, value) {
|
||||||
|
return app.localStorageSet(key, value);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseGet(key) {
|
||||||
|
return g_database ? g_database.get(key) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseSet(key, value) {
|
||||||
|
return g_database ? g_database.set(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function createIdentity() {
|
||||||
|
return ssb.createIdentity();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getIdentities() {
|
||||||
|
return ssb.getIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getAllIdentities() {
|
||||||
|
return ssb.getAllIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getBroadcasts() {
|
||||||
|
return ssb.getBroadcasts();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getConnections() {
|
||||||
|
return ssb.connections();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getStoredConnections() {
|
||||||
|
return ssb.storedConnections();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function forgetStoredConnection(connection) {
|
||||||
|
return ssb.forgetStoredConnection(connection);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function createTunnel(portal, target) {
|
||||||
|
return ssb.createTunnel(portal, target);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function connect(token) {
|
||||||
|
await ssb.connect(token);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function closeConnection(id) {
|
||||||
|
await ssb.closeConnection(id);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function appendMessage(id, message) {
|
||||||
|
return ssb.appendMessageWithIdentity(id, message);
|
||||||
|
});
|
||||||
|
core.register('message', async function message_handler(message) {
|
||||||
|
if (message.event == 'hashChange') {
|
||||||
|
g_hash = message.hash;
|
||||||
|
await tfrpc.rpc.hashChanged(message.hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tfrpc.register(function getHash(id, message) {
|
||||||
|
return g_hash;
|
||||||
|
});
|
||||||
|
tfrpc.register(function setHash(hash) {
|
||||||
|
return app.setHash(hash);
|
||||||
|
});
|
||||||
|
ssb.addEventListener('message', async function(id) {
|
||||||
|
await tfrpc.rpc.notifyNewMessage(id);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_blob(blob) {
|
||||||
|
if (Array.isArray(blob)) {
|
||||||
|
blob = Uint8Array.from(blob);
|
||||||
|
}
|
||||||
|
return await ssb.blobStore(blob);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function get_blob(id) {
|
||||||
|
return utf8Decode(await ssb.blobGet(id));
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_message(message) {
|
||||||
|
return await ssb.storeMessage(message);
|
||||||
|
});
|
||||||
|
tfrpc.register(function apps() {
|
||||||
|
return core.apps();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function try_decrypt(id, content) {
|
||||||
|
return await ssb.privateMessageDecrypt(id, content);
|
||||||
|
});
|
||||||
|
ssb.addEventListener('broadcasts', async function() {
|
||||||
|
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
|
||||||
|
});
|
||||||
|
|
||||||
|
core.register('onConnectionsChanged', async function() {
|
||||||
|
await tfrpc.rpc.set('connections', await ssb.connections());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (typeof(database) !== 'undefined') {
|
||||||
|
g_database = await database('ssb');
|
||||||
|
}
|
||||||
|
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||||
|
}
|
||||||
|
main();
|
91
apps/issues/commonmark-linkify.js
Normal file
91
apps/issues/commonmark-linkify.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
function textNode(text) {
|
||||||
|
const node = new commonmark.Node("text", undefined);
|
||||||
|
node.literal = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkNode(text, url) {
|
||||||
|
const urlNode = new commonmark.Node("link", undefined);
|
||||||
|
urlNode.destination = url;
|
||||||
|
urlNode.appendChild(textNode(text));
|
||||||
|
|
||||||
|
return urlNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMatches(text, regexp) {
|
||||||
|
// Regexp must be sticky.
|
||||||
|
regexp = new RegExp(regexp, "gm");
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
let match = regexp.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const matchText = match[0];
|
||||||
|
|
||||||
|
if (match.index > i) {
|
||||||
|
result.push([text.substring(i, match.index), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push([matchText, true]);
|
||||||
|
i = match.index + matchText.length;
|
||||||
|
|
||||||
|
match = regexp.exec(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < text.length) {
|
||||||
|
result.push([text.substring(i, text.length), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
|
||||||
|
|
||||||
|
function splitURLs(textNodes) {
|
||||||
|
const text = textNodes.map(n => n.literal).join("");
|
||||||
|
const parts = splitMatches(text, urlRegexp);
|
||||||
|
|
||||||
|
return parts.map(part => {
|
||||||
|
if (part[1]) {
|
||||||
|
return linkNode(part[0], part[0]);
|
||||||
|
} else {
|
||||||
|
return textNode(part[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transform(parsed) {
|
||||||
|
const walker = parsed.walker();
|
||||||
|
let event;
|
||||||
|
|
||||||
|
let nodes = [];
|
||||||
|
while ((event = walker.next())) {
|
||||||
|
const node = event.node;
|
||||||
|
if (event.entering && node.type === "text") {
|
||||||
|
nodes.push(node);
|
||||||
|
} else {
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
splitURLs(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
nodes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
splitURLs(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
1
apps/issues/commonmark.min.js
vendored
Normal file
1
apps/issues/commonmark.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
apps/issues/index.html
Normal file
14
apps/issues/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="color: #fff">
|
||||||
|
<head>
|
||||||
|
<title>Tilde Friends</title>
|
||||||
|
<base target="_top">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<tf-issues-app/>
|
||||||
|
<script>window.litDisableBundleWarning = true;</script>
|
||||||
|
<script src="commonmark.min.js"></script>
|
||||||
|
<script src="commonmark-linkify.js" type="module"></script>
|
||||||
|
<script src="script.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
120
apps/issues/lit-all.min.js
vendored
Normal file
120
apps/issues/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/issues/lit-all.min.js.map
Normal file
1
apps/issues/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
260
apps/issues/script.js
Normal file
260
apps/issues/script.js
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import * as tfutils from './tf-utils.js';
|
||||||
|
|
||||||
|
const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256';
|
||||||
|
|
||||||
|
class TfIdPickerElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
ids: {type: Array},
|
||||||
|
selected: {type: String},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.selected = await tfrpc.rpc.localStorageGet('whoami');
|
||||||
|
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
changed(event) {
|
||||||
|
this.selected = event.srcElement.value;
|
||||||
|
tfrpc.rpc.localStorageSet('whoami', this.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.ids) {
|
||||||
|
return html`
|
||||||
|
<select @change=${this.changed} style="max-width: 100%">
|
||||||
|
${(this.ids).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return html`<div>Loading...</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('tf-id-picker', TfIdPickerElement);
|
||||||
|
|
||||||
|
class TfComposeElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
value: {type: String},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
input() {
|
||||||
|
let input = this.renderRoot.getElementById('input');
|
||||||
|
let preview = this.renderRoot.getElementById('preview');
|
||||||
|
if (input && preview) {
|
||||||
|
preview.innerHTML = tfutils.markdown(input.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
submit() {
|
||||||
|
this.dispatchEvent(new CustomEvent('tf-submit', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
value: this.renderRoot.getElementById('input').value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
this.renderRoot.getElementById('input').value = '';
|
||||||
|
this.input();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<textarea id="input" @input=${this.input} style="flex: 1 1">${this.value}</textarea>
|
||||||
|
<div id="preview" style="flex: 1 1"></div>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit" @click=${this.submit}></input>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('tf-compose', TfComposeElement);
|
||||||
|
|
||||||
|
class TfIssuesAppElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
issues: {type: Array},
|
||||||
|
selected: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.issues = [];
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
let issues = {};
|
||||||
|
let messages = await tfrpc.rpc.query(`
|
||||||
|
WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON
|
||||||
|
messages.id = messages_refs.message
|
||||||
|
WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
|
||||||
|
edits AS (SELECT messages.* FROM issues JOIN messages_refs ON
|
||||||
|
issues.id = messages_refs.ref JOIN messages ON
|
||||||
|
messages.id = messages_refs.message
|
||||||
|
WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post'))
|
||||||
|
SELECT * FROM issues
|
||||||
|
UNION
|
||||||
|
SELECT * FROM edits ORDER BY timestamp
|
||||||
|
`, [k_project]);
|
||||||
|
for (let message of messages) {
|
||||||
|
let content = JSON.parse(message.content);
|
||||||
|
switch (content.type) {
|
||||||
|
case 'issue':
|
||||||
|
issues[message.id] = {
|
||||||
|
id: message.id,
|
||||||
|
author: message.author,
|
||||||
|
text: content.text,
|
||||||
|
updates: [],
|
||||||
|
created: message.timestamp,
|
||||||
|
open: true,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'issue-edit':
|
||||||
|
case 'post':
|
||||||
|
for (let issue of (content.issues || [])) {
|
||||||
|
if (issues[issue.link]) {
|
||||||
|
if (issue.open !== undefined) {
|
||||||
|
issues[issue.link].open = issue.open;
|
||||||
|
message.open = issue.open;
|
||||||
|
}
|
||||||
|
issues[issue.link].updates.push(message);
|
||||||
|
issues[issue.link].updated = message.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.issues = Object.values(issues).sort((x, y) => y.created - x.created);
|
||||||
|
if (this.selected) {
|
||||||
|
for (let issue of this.issues) {
|
||||||
|
if (issue.id == this.selected.id) {
|
||||||
|
this.selected = issue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_issue_table_row(issue) {
|
||||||
|
return html`
|
||||||
|
<tr>
|
||||||
|
<td>${issue.open ? 'open' : 'closed'}</td>
|
||||||
|
<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td>
|
||||||
|
<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}>
|
||||||
|
${issue.text.split('\n')?.[0]}
|
||||||
|
</td>
|
||||||
|
<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_update(update) {
|
||||||
|
let content = JSON.parse(update.content);
|
||||||
|
let message;
|
||||||
|
if (content.text) {
|
||||||
|
message = unsafeHTML(tfutils.markdown(content.text));
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div style="border-left: 2px solid #fff; padding-left: 8px">
|
||||||
|
<div>${new Date(update.timestamp).toLocaleString()}</div>
|
||||||
|
<div>${update.author}</div>
|
||||||
|
<div>${message}</div>
|
||||||
|
<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set_open(id, open) {
|
||||||
|
if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) {
|
||||||
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||||
|
await tfrpc.rpc.appendMessage(whoami, {
|
||||||
|
type: 'issue-edit',
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
link: id,
|
||||||
|
open: open,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_issue(event) {
|
||||||
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||||
|
await tfrpc.rpc.appendMessage(whoami, {
|
||||||
|
type: 'issue',
|
||||||
|
project: k_project,
|
||||||
|
text: event.detail.value,
|
||||||
|
});
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reply_to_issue(event) {
|
||||||
|
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||||
|
await tfrpc.rpc.appendMessage(whoami, {
|
||||||
|
type: 'post',
|
||||||
|
text: event.detail.value,
|
||||||
|
root: this.selected.id,
|
||||||
|
branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
link: this.selected.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let header = html`
|
||||||
|
<h1>Tilde Friends Issues</h1>
|
||||||
|
<tf-id-picker id="picker"></tf-id-picker>
|
||||||
|
`;
|
||||||
|
if (this.selected) {
|
||||||
|
return html`
|
||||||
|
${header}
|
||||||
|
<div>
|
||||||
|
<input type="button" value="Back" @click=${() => this.selected = undefined}></input>
|
||||||
|
${this.selected.open ?
|
||||||
|
html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` :
|
||||||
|
html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`}
|
||||||
|
</div>
|
||||||
|
<div>${new Date(this.selected.created).toLocaleString()}</div>
|
||||||
|
<div>${this.selected.author}</div>
|
||||||
|
<div>${this.selected.id}</div>
|
||||||
|
<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div>
|
||||||
|
${this.selected.updates.map(x => this.render_update(x))}
|
||||||
|
<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return html`
|
||||||
|
${header}
|
||||||
|
<h2>New Issue</h2>
|
||||||
|
<tf-compose @tf-submit=${this.create_issue}></tf-compose>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Author</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
${this.issues.map(x => this.render_issue_table_row(x))}
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-issues-app', TfIssuesAppElement);
|
91
apps/issues/tf-utils.js
Normal file
91
apps/issues/tf-utils.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import * as linkify from './commonmark-linkify.js';
|
||||||
|
|
||||||
|
function image(node, entering) {
|
||||||
|
if (node.firstChild?.type === 'text' &&
|
||||||
|
node.firstChild.literal.startsWith('video:')) {
|
||||||
|
if (entering) {
|
||||||
|
this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||||
|
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
this.lit('</video>');
|
||||||
|
}
|
||||||
|
} else if (node.firstChild?.type === 'text' &&
|
||||||
|
node.firstChild.literal.startsWith('audio:')) {
|
||||||
|
if (entering) {
|
||||||
|
this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||||
|
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
this.lit('</audio>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (entering) {
|
||||||
|
if (this.disableTags === 0) {
|
||||||
|
this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>');
|
||||||
|
if (this.options.safe && potentiallyUnsafe(node.destination)) {
|
||||||
|
this.lit('<img src="" alt="');
|
||||||
|
} else {
|
||||||
|
this.lit('<img src="' + this.esc(node.destination) + '" alt="');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
if (this.disableTags === 0) {
|
||||||
|
if (node.title) {
|
||||||
|
this.lit('" title="' + this.esc(node.title));
|
||||||
|
}
|
||||||
|
this.lit('" />');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdown(md) {
|
||||||
|
var reader = new commonmark.Parser({safe: true});
|
||||||
|
var writer = new commonmark.HtmlRenderer();
|
||||||
|
writer.image = image;
|
||||||
|
var parsed = reader.parse(md || '');
|
||||||
|
parsed = linkify.transform(parsed);
|
||||||
|
var walker = parsed.walker();
|
||||||
|
var event, node;
|
||||||
|
while ((event = walker.next())) {
|
||||||
|
node = event.node;
|
||||||
|
if (event.entering) {
|
||||||
|
if (node.type == 'link') {
|
||||||
|
if (node.destination.startsWith('@') &&
|
||||||
|
node.destination.endsWith('.ed25519')) {
|
||||||
|
node.destination = '#' + node.destination;
|
||||||
|
} else if (node.destination.startsWith('%') &&
|
||||||
|
node.destination.endsWith('.sha256')) {
|
||||||
|
node.destination = '#' + node.destination;
|
||||||
|
} else if (node.destination.startsWith('&') &&
|
||||||
|
node.destination.endsWith('.sha256')) {
|
||||||
|
node.destination = '/' + node.destination + '/view';
|
||||||
|
}
|
||||||
|
} else if (node.type == 'image') {
|
||||||
|
if (node.destination.startsWith('&')) {
|
||||||
|
node.destination = '/' + node.destination + '/view';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writer.render(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function human_readable_size(bytes) {
|
||||||
|
let v = bytes;
|
||||||
|
let u = 'B';
|
||||||
|
for (let unit of ['kB', 'MB', 'GB']) {
|
||||||
|
if (v > 1024) {
|
||||||
|
v /= 1024;
|
||||||
|
u = unit;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${Math.round(v * 10) / 10} ${u}`;
|
||||||
|
}
|
4
apps/sneaker.json
Normal file
4
apps/sneaker.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "👟"
|
||||||
|
}
|
30
apps/sneaker/app.js
Normal file
30
apps/sneaker/app.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
|
||||||
|
tfrpc.register(async function getAllIdentities() {
|
||||||
|
return ssb.getAllIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
tfrpc.register(async function store_blob(blob) {
|
||||||
|
if (Array.isArray(blob)) {
|
||||||
|
blob = Uint8Array.from(blob);
|
||||||
|
}
|
||||||
|
return await ssb.blobStore(blob);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function get_blob(id) {
|
||||||
|
return Array.from(new Uint8Array(await ssb.blobGet(id)));
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_message(message) {
|
||||||
|
return await ssb.storeMessage(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||||
|
}
|
||||||
|
main();
|
3
apps/sneaker/filesaver.min.js
vendored
Normal file
3
apps/sneaker/filesaver.min.js
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
|
||||||
|
|
||||||
|
//# sourceMappingURL=FileSaver.min.js.map
|
14
apps/sneaker/index.html
Normal file
14
apps/sneaker/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="color: #fff">
|
||||||
|
<head>
|
||||||
|
<title>Tilde Friends</title>
|
||||||
|
<base target="_top">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<tf-sneaker-app/>
|
||||||
|
<script>window.litDisableBundleWarning = true;</script>
|
||||||
|
<script src="filesaver.min.js"></script>
|
||||||
|
<script src="jszip.min.js"></script>
|
||||||
|
<script src="script.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
apps/sneaker/jszip.min.js
vendored
Normal file
13
apps/sneaker/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
120
apps/sneaker/lit-all.min.js
vendored
Normal file
120
apps/sneaker/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/sneaker/lit-all.min.js.map
Normal file
1
apps/sneaker/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
232
apps/sneaker/script.js
Normal file
232
apps/sneaker/script.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
import {LitElement, html} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
|
||||||
|
class TfSneakerAppElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
feeds: {type: Object},
|
||||||
|
progress: {type: Object},
|
||||||
|
result: {type: String},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.feeds = [];
|
||||||
|
this.progress = undefined;
|
||||||
|
this.result = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
let q = this.renderRoot.getElementById('search').value;
|
||||||
|
let result = await tfrpc.rpc.query(`
|
||||||
|
SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name
|
||||||
|
FROM messages_fts(?)
|
||||||
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||||
|
WHERE
|
||||||
|
json_extract(messages.content, '$.type') = 'about' AND
|
||||||
|
json_extract(messages.content, '$.about') = messages.author AND
|
||||||
|
json_extract(messages.content, '$.name') IS NOT NULL
|
||||||
|
GROUP BY messages.author
|
||||||
|
HAVING MAX(messages.sequence)
|
||||||
|
ORDER BY COUNT(*) DESC
|
||||||
|
`,
|
||||||
|
[`"${q.replaceAll('"', '""')}"`]);
|
||||||
|
this.feeds = Object.fromEntries(result.map(x => [x.id, x.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
format_message(message) {
|
||||||
|
let out = {
|
||||||
|
previous: message.previous ?? null,
|
||||||
|
};
|
||||||
|
if (message.sequence_before_author) {
|
||||||
|
out.sequence = message.sequence;
|
||||||
|
out.author = message.author;
|
||||||
|
} else {
|
||||||
|
out.author = message.author;
|
||||||
|
out.sequence = message.sequence;
|
||||||
|
}
|
||||||
|
out.timestamp = message.timestamp;
|
||||||
|
out.hash = message.hash;
|
||||||
|
out.content = JSON.parse(message.content);
|
||||||
|
out.signature = message.signature;
|
||||||
|
return {key: message.id, value: out};
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitize(value) {
|
||||||
|
return value.replaceAll('/', '_').replaceAll('+', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
guess_ext(data) {
|
||||||
|
function startsWith(prefix) {
|
||||||
|
if (data.length < prefix.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < prefix.length; i++) {
|
||||||
|
if (prefix[i] !== null && data[i] !== prefix[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
|
||||||
|
startsWith(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) ||
|
||||||
|
startsWith(data, [0xff, 0xd8, 0xff, 0xee]) ||
|
||||||
|
startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
|
||||||
|
return '.jpg';
|
||||||
|
} else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
||||||
|
return '.png';
|
||||||
|
} else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
|
||||||
|
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
|
||||||
|
return '.gif';
|
||||||
|
} else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) {
|
||||||
|
return '.webp';
|
||||||
|
} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) {
|
||||||
|
return '.svg';
|
||||||
|
} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||||
|
return '.mp3';
|
||||||
|
} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) ||
|
||||||
|
startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||||
|
return '.mp4';
|
||||||
|
} else {
|
||||||
|
return '.bin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(id) {
|
||||||
|
let all_messages = '';
|
||||||
|
let sequence = -1;
|
||||||
|
let messages_done = 0;
|
||||||
|
let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total;
|
||||||
|
while (true) {
|
||||||
|
let messages = await tfrpc.rpc.query(
|
||||||
|
'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100',
|
||||||
|
[id, sequence]
|
||||||
|
);
|
||||||
|
if (messages?.length) {
|
||||||
|
all_messages += messages.map(x => JSON.stringify(this.format_message(x))).join('\n') + '\n';
|
||||||
|
sequence = messages[messages.length - 1].sequence;
|
||||||
|
messages_done += messages.length;
|
||||||
|
this.progress = {name: 'messages', value: messages_done, max: messages_max};
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let zip = new JSZip();
|
||||||
|
zip.file(`message/classic/${this.sanitize(id)}.ndjson`, all_messages);
|
||||||
|
|
||||||
|
let blobs = await tfrpc.rpc.query(
|
||||||
|
`SELECT messages_refs.ref AS id
|
||||||
|
FROM messages
|
||||||
|
JOIN messages_refs ON messages.id = messages_refs.message
|
||||||
|
WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`,
|
||||||
|
[id]);
|
||||||
|
let blobs_done = 0;
|
||||||
|
for (let row of blobs) {
|
||||||
|
this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
|
||||||
|
let blob;
|
||||||
|
try {
|
||||||
|
blob = await tfrpc.rpc.get_blob(row.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Failed to get ${row.id}: ${e.message}`);
|
||||||
|
}
|
||||||
|
if (blob) {
|
||||||
|
zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob));
|
||||||
|
}
|
||||||
|
blobs_done++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.progress = {name: 'saving'};
|
||||||
|
let blob = await zip.generateAsync({type: 'blob'});
|
||||||
|
saveAs(blob, `${this.sanitize(id)}.zip`);
|
||||||
|
this.progress = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
keypress(event) {
|
||||||
|
if (event.key == 'Enter') {
|
||||||
|
this.search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async import(event) {
|
||||||
|
let file = event.target.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.progress = {name: 'loading'};
|
||||||
|
let zip = new JSZip();
|
||||||
|
file = await zip.loadAsync(file);
|
||||||
|
let messages = [];
|
||||||
|
let blobs = [];
|
||||||
|
file.forEach(function(path, entry) {
|
||||||
|
if (!entry.dir) {
|
||||||
|
if (path.startsWith('message/classic/')) {
|
||||||
|
messages.push(entry);
|
||||||
|
} else {
|
||||||
|
blobs.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let success = {messages: 0, blobs: 0};
|
||||||
|
let progress = 0;
|
||||||
|
let total_messages = 0;
|
||||||
|
for (let entry of messages) {
|
||||||
|
let lines = (await entry.async('string')).split('\n');
|
||||||
|
total_messages += lines.length;
|
||||||
|
for (let line of lines) {
|
||||||
|
if (!line.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let message = JSON.parse(line);
|
||||||
|
this.progress = {name: 'messages', value: progress++, max: total_messages};
|
||||||
|
if (await tfrpc.rpc.store_message(message.value)) {
|
||||||
|
success.messages++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress = 0;
|
||||||
|
for (let blob of blobs) {
|
||||||
|
this.progress = {name: 'blobs', value: progress++, max: blobs.length};
|
||||||
|
if (await tfrpc.rpc.store_blob(await blob.async('arraybuffer'))) {
|
||||||
|
success.blobs++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.progress = undefined;
|
||||||
|
this.result = `imported ${success.messages} messages and ${success.blobs} blobs`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let progress;
|
||||||
|
if (this.progress) {
|
||||||
|
if (this.progress.max) {
|
||||||
|
progress = html`<div><label for="progress">${this.progress.name}</label><progress value=${this.progress.value} max=${this.progress.max}></progress></div>`;
|
||||||
|
} else {
|
||||||
|
progress = html`<div><span>${this.progress.name}</span></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html`<h1>SSB 👟net</h1>
|
||||||
|
<code>${this.result}</code>
|
||||||
|
${progress}
|
||||||
|
|
||||||
|
<h2>Import</h2>
|
||||||
|
<input type="file" id="import" @change=${this.import}></input>
|
||||||
|
|
||||||
|
<h2>Export</h2>
|
||||||
|
<input type="text" id="search" @keypress=${this.keypress}></input>
|
||||||
|
<input type="button" value="Search Users" @click=${this.search}></input>
|
||||||
|
<ul>
|
||||||
|
${Object.entries(this.feeds).map(([id, name]) => html`
|
||||||
|
<li>
|
||||||
|
${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
|
||||||
|
${name}
|
||||||
|
<code style="color: #ccc">${id}</code>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('tf-sneaker-app', TfSneakerAppElement);
|
4
apps/ssb.json
Normal file
4
apps/ssb.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"type": "tildefriends-app",
|
||||||
|
"emoji": "🐌"
|
||||||
|
}
|
108
apps/ssb/app.js
Normal file
108
apps/ssb/app.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import * as tfrpc from '/tfrpc.js';
|
||||||
|
|
||||||
|
let g_database;
|
||||||
|
let g_hash;
|
||||||
|
|
||||||
|
tfrpc.register(async function localStorageGet(key) {
|
||||||
|
return app.localStorageGet(key);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function localStorageSet(key, value) {
|
||||||
|
return app.localStorageSet(key, value);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseGet(key) {
|
||||||
|
return g_database ? g_database.get(key) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function databaseSet(key, value) {
|
||||||
|
return g_database ? g_database.set(key, value) : undefined;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function createIdentity() {
|
||||||
|
return ssb.createIdentity();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getIdentities() {
|
||||||
|
return ssb.getIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getAllIdentities() {
|
||||||
|
return ssb.getAllIdentities();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getBroadcasts() {
|
||||||
|
return ssb.getBroadcasts();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getConnections() {
|
||||||
|
return ssb.connections();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function getStoredConnections() {
|
||||||
|
return ssb.storedConnections();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function forgetStoredConnection(connection) {
|
||||||
|
return ssb.forgetStoredConnection(connection);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function createTunnel(portal, target) {
|
||||||
|
return ssb.createTunnel(portal, target);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function connect(token) {
|
||||||
|
await ssb.connect(token);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function closeConnection(id) {
|
||||||
|
await ssb.closeConnection(id);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function query(sql, args) {
|
||||||
|
let result = [];
|
||||||
|
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||||
|
result.push(row);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
tfrpc.register(async function appendMessage(id, message) {
|
||||||
|
return ssb.appendMessageWithIdentity(id, message);
|
||||||
|
});
|
||||||
|
core.register('message', async function message_handler(message) {
|
||||||
|
if (message.event == 'hashChange') {
|
||||||
|
g_hash = message.hash;
|
||||||
|
await tfrpc.rpc.hashChanged(message.hash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tfrpc.register(function getHash(id, message) {
|
||||||
|
return g_hash;
|
||||||
|
});
|
||||||
|
tfrpc.register(function setHash(hash) {
|
||||||
|
return app.setHash(hash);
|
||||||
|
});
|
||||||
|
ssb.addEventListener('message', async function(id) {
|
||||||
|
await tfrpc.rpc.notifyNewMessage(id);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_blob(blob) {
|
||||||
|
if (Array.isArray(blob)) {
|
||||||
|
blob = Uint8Array.from(blob);
|
||||||
|
}
|
||||||
|
return await ssb.blobStore(blob);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function get_blob(id) {
|
||||||
|
return utf8Decode(await ssb.blobGet(id));
|
||||||
|
});
|
||||||
|
tfrpc.register(async function store_message(message) {
|
||||||
|
return await ssb.storeMessage(message);
|
||||||
|
});
|
||||||
|
tfrpc.register(function apps() {
|
||||||
|
return core.apps();
|
||||||
|
});
|
||||||
|
tfrpc.register(async function try_decrypt(id, content) {
|
||||||
|
return await ssb.privateMessageDecrypt(id, content);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function encrypt(id, recipients, content) {
|
||||||
|
return await ssb.privateMessageEncrypt(id, recipients, content);
|
||||||
|
});
|
||||||
|
ssb.addEventListener('broadcasts', async function() {
|
||||||
|
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
|
||||||
|
});
|
||||||
|
|
||||||
|
core.register('onConnectionsChanged', async function() {
|
||||||
|
await tfrpc.rpc.set('connections', await ssb.connections());
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (typeof(database) !== 'undefined') {
|
||||||
|
g_database = await database('ssb');
|
||||||
|
}
|
||||||
|
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||||
|
}
|
||||||
|
main();
|
90
apps/ssb/commonmark-hashtag.js
Normal file
90
apps/ssb/commonmark-hashtag.js
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
function textNode(text) {
|
||||||
|
const node = new commonmark.Node("text", undefined);
|
||||||
|
node.literal = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkNode(text, link) {
|
||||||
|
const linkNode = new commonmark.Node("link", undefined);
|
||||||
|
linkNode.destination = `#q=${encodeURIComponent(link)}`;
|
||||||
|
linkNode.appendChild(textNode(text));
|
||||||
|
return linkNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMatches(text, regexp) {
|
||||||
|
// Regexp must be sticky.
|
||||||
|
regexp = new RegExp(regexp, "gm");
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
let match = regexp.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const matchText = match[0];
|
||||||
|
|
||||||
|
if (match.index > i) {
|
||||||
|
result.push([text.substring(i, match.index), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push([matchText, true]);
|
||||||
|
i = match.index + matchText.length;
|
||||||
|
|
||||||
|
match = regexp.exec(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < text.length) {
|
||||||
|
result.push([text.substring(i, text.length), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regex = new RegExp("(?<!\w)#[\\w-]+");
|
||||||
|
|
||||||
|
function split(textNodes) {
|
||||||
|
const text = textNodes.map(n => n.literal).join("");
|
||||||
|
const parts = splitMatches(text, regex);
|
||||||
|
|
||||||
|
return parts.map(part => {
|
||||||
|
if (part[1]) {
|
||||||
|
return linkNode(part[0], part[0]);
|
||||||
|
} else {
|
||||||
|
return textNode(part[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transform(parsed) {
|
||||||
|
const walker = parsed.walker();
|
||||||
|
let event;
|
||||||
|
|
||||||
|
let nodes = [];
|
||||||
|
while ((event = walker.next())) {
|
||||||
|
const node = event.node;
|
||||||
|
if (event.entering && node.type === "text") {
|
||||||
|
nodes.push(node);
|
||||||
|
} else {
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
split(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
nodes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
split(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
91
apps/ssb/commonmark-linkify.js
Normal file
91
apps/ssb/commonmark-linkify.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
function textNode(text) {
|
||||||
|
const node = new commonmark.Node("text", undefined);
|
||||||
|
node.literal = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkNode(text, url) {
|
||||||
|
const urlNode = new commonmark.Node("link", undefined);
|
||||||
|
urlNode.destination = url;
|
||||||
|
urlNode.appendChild(textNode(text));
|
||||||
|
|
||||||
|
return urlNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitMatches(text, regexp) {
|
||||||
|
// Regexp must be sticky.
|
||||||
|
regexp = new RegExp(regexp, "gm");
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
let match = regexp.exec(text);
|
||||||
|
while (match) {
|
||||||
|
const matchText = match[0];
|
||||||
|
|
||||||
|
if (match.index > i) {
|
||||||
|
result.push([text.substring(i, match.index), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push([matchText, true]);
|
||||||
|
i = match.index + matchText.length;
|
||||||
|
|
||||||
|
match = regexp.exec(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < text.length) {
|
||||||
|
result.push([text.substring(i, text.length), false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
|
||||||
|
|
||||||
|
function splitURLs(textNodes) {
|
||||||
|
const text = textNodes.map(n => n.literal).join("");
|
||||||
|
const parts = splitMatches(text, urlRegexp);
|
||||||
|
|
||||||
|
return parts.map(part => {
|
||||||
|
if (part[1]) {
|
||||||
|
return linkNode(part[0], part[0]);
|
||||||
|
} else {
|
||||||
|
return textNode(part[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transform(parsed) {
|
||||||
|
const walker = parsed.walker();
|
||||||
|
let event;
|
||||||
|
|
||||||
|
let nodes = [];
|
||||||
|
while ((event = walker.next())) {
|
||||||
|
const node = event.node;
|
||||||
|
if (event.entering && node.type === "text") {
|
||||||
|
nodes.push(node);
|
||||||
|
} else {
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
splitURLs(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
nodes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
splitURLs(nodes)
|
||||||
|
.reverse()
|
||||||
|
.forEach(newNode => {
|
||||||
|
nodes[0].insertAfter(newNode);
|
||||||
|
});
|
||||||
|
nodes.forEach(n => n.unlink());
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
1
apps/ssb/commonmark.min.js
vendored
Normal file
1
apps/ssb/commonmark.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
112
apps/ssb/emojis.js
Normal file
112
apps/ssb/emojis.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
let g_emojis;
|
||||||
|
|
||||||
|
function get_emojis() {
|
||||||
|
if (g_emojis) {
|
||||||
|
return Promise.resolve(g_emojis);
|
||||||
|
}
|
||||||
|
return fetch('emojis.json').then(function(result) {
|
||||||
|
g_emojis = result.json();
|
||||||
|
return g_emojis;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function picker(callback, anchor) {
|
||||||
|
get_emojis().then(function(json) {
|
||||||
|
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.position = 'absolute';
|
||||||
|
div.style.minWidth = 'min(16em, 90vw)';
|
||||||
|
div.style.width = 'min(16em, 90vw)';
|
||||||
|
div.style.maxWidth = 'min(16em, 90vw)';
|
||||||
|
div.style.maxHeight = '16em';
|
||||||
|
div.style.overflow = 'scroll';
|
||||||
|
div.style.fontWeight = 'bold';
|
||||||
|
div.style.fontSize = 'xx-large';
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.style.display = 'block';
|
||||||
|
input.style.boxSizing = 'border-box';
|
||||||
|
input.style.width = '100%';
|
||||||
|
input.style.margin = '0';
|
||||||
|
input.style.position = 'relative';
|
||||||
|
div.appendChild(input);
|
||||||
|
let list = document.createElement('div');
|
||||||
|
div.appendChild(list);
|
||||||
|
div.addEventListener('mousedown', function(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
console.log('emoji cleanup');
|
||||||
|
div.parentElement.removeChild(div);
|
||||||
|
window.removeEventListener('keydown', key_down);
|
||||||
|
console.log('removing click');
|
||||||
|
document.body.removeEventListener('mousedown', cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function key_down(event) {
|
||||||
|
if (event.key == 'Escape') {
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chosen(event) {
|
||||||
|
console.log(event.srcElement.innerText);
|
||||||
|
callback(event.srcElement.innerText);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
while (list.firstChild) {
|
||||||
|
list.removeChild(list.firstChild);
|
||||||
|
}
|
||||||
|
let search = input.value.toLowerCase();
|
||||||
|
let any_at_all = false;
|
||||||
|
for (let row of Object.entries(json)) {
|
||||||
|
let header = document.createElement('div');
|
||||||
|
header.appendChild(document.createTextNode(row[0]));
|
||||||
|
list.appendChild(header);
|
||||||
|
let any = false;
|
||||||
|
for (let entry of Object.entries(row[1])) {
|
||||||
|
if (search &&
|
||||||
|
search.length &&
|
||||||
|
entry[0].toLowerCase().indexOf(search) == -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let emoji = document.createElement('span');
|
||||||
|
const k_size = '1.25em';
|
||||||
|
emoji.style.display = 'inline-block';
|
||||||
|
emoji.style.overflow = 'hidden';
|
||||||
|
emoji.style.cursor = 'pointer';
|
||||||
|
emoji.onclick = chosen;
|
||||||
|
emoji.title = entry[0];
|
||||||
|
emoji.appendChild(document.createTextNode(entry[1]));
|
||||||
|
list.appendChild(emoji);
|
||||||
|
any = true;
|
||||||
|
any_at_all = true;
|
||||||
|
}
|
||||||
|
if (!any) {
|
||||||
|
list.removeChild(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!any_at_all) {
|
||||||
|
list.appendChild(document.createTextNode('No matches found.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
input.oninput = refresh;
|
||||||
|
document.body.appendChild(div);
|
||||||
|
div.style.position = 'fixed';
|
||||||
|
div.style.top = '50%';
|
||||||
|
div.style.left = '50%';
|
||||||
|
div.style.transform = 'translate(-50%, -50%)';
|
||||||
|
input.focus();
|
||||||
|
console.log('adding click');
|
||||||
|
document.body.addEventListener('mousedown', cleanup);
|
||||||
|
window.addEventListener('keydown', key_down);
|
||||||
|
});
|
||||||
|
}
|
1
apps/ssb/emojis.json
Normal file
1
apps/ssb/emojis.json
Normal file
File diff suppressed because one or more lines are too long
3
apps/ssb/filesaver.min.js
vendored
Normal file
3
apps/ssb/filesaver.min.js
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
|
||||||
|
|
||||||
|
//# sourceMappingURL=FileSaver.min.js.map
|
22
apps/ssb/index.html
Normal file
22
apps/ssb/index.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html style="color: #fff">
|
||||||
|
<head>
|
||||||
|
<title>Tilde Friends</title>
|
||||||
|
<base target="_top">
|
||||||
|
<link rel="stylesheet" href="tribute.css" />
|
||||||
|
<style>
|
||||||
|
.tribute-container {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<tf-app/>
|
||||||
|
<script>window.litDisableBundleWarning = true;</script>
|
||||||
|
<script src="filesaver.min.js"></script>
|
||||||
|
<script src="commonmark.min.js"></script>
|
||||||
|
<script src="commonmark-linkify.js" type="module"></script>
|
||||||
|
<script src="commonmark-hashtag.js" type="module"></script>
|
||||||
|
<script src="script.js" type="module"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
120
apps/ssb/lit-all.min.js
vendored
Normal file
120
apps/ssb/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/ssb/lit-all.min.js.map
Normal file
1
apps/ssb/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
17
apps/ssb/script.js
Normal file
17
apps/ssb/script.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {LitElement, html} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
|
||||||
|
import * as tf_id_picker from './tf-id-picker.js';
|
||||||
|
import * as tf_app from './tf-app.js';
|
||||||
|
import * as tf_message from './tf-message.js';
|
||||||
|
import * as tf_user from './tf-user.js';
|
||||||
|
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_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';
|
369
apps/ssb/tf-app.js
Normal file
369
apps/ssb/tf-app.js
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
import {LitElement, html, css, guard, until} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} 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},
|
||||||
|
loaded: {type: Boolean},
|
||||||
|
following: {type: Array},
|
||||||
|
users: {type: Object},
|
||||||
|
ids: {type: Array},
|
||||||
|
tags: {type: Array},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.hash = '#';
|
||||||
|
this.unread = [];
|
||||||
|
this.tab = 'news';
|
||||||
|
this.broadcasts = [];
|
||||||
|
this.connections = [];
|
||||||
|
this.following = [];
|
||||||
|
this.users = {};
|
||||||
|
this.loaded = false;
|
||||||
|
this.tags = [];
|
||||||
|
tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; });
|
||||||
|
tfrpc.rpc.getConnections().then(c => { self.connections = c || []; });
|
||||||
|
tfrpc.rpc.getHash().then(hash => self.set_hash(hash));
|
||||||
|
tfrpc.register(function hashChanged(hash) {
|
||||||
|
self.set_hash(hash);
|
||||||
|
});
|
||||||
|
tfrpc.register(async function notifyNewMessage(id) {
|
||||||
|
await self.fetch_new_message(id);
|
||||||
|
});
|
||||||
|
tfrpc.register(function set(name, value) {
|
||||||
|
if (name === 'broadcasts') {
|
||||||
|
self.broadcasts = value;
|
||||||
|
} else if (name === 'connections') {
|
||||||
|
self.connections = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.initial_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initial_load() {
|
||||||
|
let whoami = await tfrpc.rpc.localStorageGet('whoami');
|
||||||
|
let ids = (await tfrpc.rpc.getIdentities()) || [];
|
||||||
|
this.whoami = whoami ?? (ids.length ? ids[0] : undefined);
|
||||||
|
this.ids = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_hash(hash) {
|
||||||
|
this.hash = 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 contacts_internal(id, last_row_id, following, max_row_id) {
|
||||||
|
let result = Object.assign({}, following[id] || {});
|
||||||
|
result.following = result.following || {};
|
||||||
|
result.blocking = result.blocking || {};
|
||||||
|
let contacts = await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
SELECT content FROM messages
|
||||||
|
WHERE author = ? AND
|
||||||
|
rowid > ? AND
|
||||||
|
rowid <= ? AND
|
||||||
|
json_extract(content, '$.type') = 'contact'
|
||||||
|
ORDER BY sequence
|
||||||
|
`,
|
||||||
|
[id, last_row_id, max_row_id]);
|
||||||
|
for (let row of contacts) {
|
||||||
|
let contact = JSON.parse(row.content);
|
||||||
|
if (contact.following === true) {
|
||||||
|
result.following[contact.contact] = true;
|
||||||
|
} else if (contact.following === false) {
|
||||||
|
delete result.following[contact.contact];
|
||||||
|
} else if (contact.blocking === true) {
|
||||||
|
result.blocking[contact.contact] = true;
|
||||||
|
} else if (contact.blocking === false) {
|
||||||
|
delete result.blocking[contact.contact];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
following[id] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async contact(id, last_row_id, following, max_row_id) {
|
||||||
|
return await this.contacts_internal(id, last_row_id, following, max_row_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
|
||||||
|
let contacts = await Promise.all([...new Set(ids)].map(x => this.contact(x, last_row_id, following, max_row_id)));
|
||||||
|
let result = {};
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
let id = ids[i];
|
||||||
|
let contact = contacts[i];
|
||||||
|
let all_blocking = Object.assign({}, contact.blocking, blocking);
|
||||||
|
let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
|
||||||
|
let deeper = depth > 1 ? await this.following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
|
||||||
|
result[id] = [id, ...found, ...deeper];
|
||||||
|
}
|
||||||
|
return [...new Set(Object.values(result).flat())];
|
||||||
|
}
|
||||||
|
|
||||||
|
async following_deep(ids, depth, blocking) {
|
||||||
|
const k_cache_version = 5;
|
||||||
|
let cache = await tfrpc.rpc.databaseGet('following');
|
||||||
|
cache = cache ? JSON.parse(cache) : {};
|
||||||
|
if (cache.version !== k_cache_version) {
|
||||||
|
cache = {
|
||||||
|
version: k_cache_version,
|
||||||
|
following: {},
|
||||||
|
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 result = await this.following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
|
||||||
|
cache.last_row_id = max_row_id;
|
||||||
|
let store = JSON.stringify(cache);
|
||||||
|
/* 2023-02-20: Exceeding message size. */
|
||||||
|
//if (store.length < 512 * 1024) {
|
||||||
|
await tfrpc.rpc.databaseSet('following', store);
|
||||||
|
//}
|
||||||
|
return [result, cache.following];
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch_about(ids, users) {
|
||||||
|
const k_cache_version = 1;
|
||||||
|
let cache = await tfrpc.rpc.databaseGet('about');
|
||||||
|
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;
|
||||||
|
for (let id of Object.keys(cache.about)) {
|
||||||
|
if (ids.indexOf(id) == -1) {
|
||||||
|
delete cache.about[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let abouts = await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
messages.*
|
||||||
|
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.*
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
return Object.assign({}, users);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch_new_message(id) {
|
||||||
|
let messages = await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
SELECT messages.*
|
||||||
|
FROM messages
|
||||||
|
JOIN json_each(?) AS following ON messages.author = following.value
|
||||||
|
WHERE messages.id = ?
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
JSON.stringify(this.following),
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
if (messages && messages.length) {
|
||||||
|
this.unread = [...this.unread, ...messages];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _handle_whoami_changed(event) {
|
||||||
|
let old_id = this.whoami;
|
||||||
|
let new_id = event.srcElement.selected;
|
||||||
|
console.log('received', new_id);
|
||||||
|
if (this.whoami !== new_id) {
|
||||||
|
console.log(event);
|
||||||
|
this.whoami = new_id;
|
||||||
|
console.log(`whoami ${old_id} => ${new_id}`);
|
||||||
|
await tfrpc.rpc.localStorageSet('whoami', new_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_id_picker() {
|
||||||
|
return html`
|
||||||
|
<tf-id-picker id="picker" selected=${this.whoami} .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker>
|
||||||
|
<button @click=${this.create_identity} id="create_identity">Create Identity</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load_recent_tags() {
|
||||||
|
let start = new Date();
|
||||||
|
this.tags = await tfrpc.rpc.query(`
|
||||||
|
WITH
|
||||||
|
recent AS (SELECT id, 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]);
|
||||||
|
console.log('tags took', (new Date() - start) / 1000.0, 'seconds');
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
let whoami = this.whoami;
|
||||||
|
let tags = this.load_recent_tags();
|
||||||
|
let [following, users] = await this.following_deep([whoami], 2, {});
|
||||||
|
users = await this.fetch_about(following.sort(), users);
|
||||||
|
this.following = following;
|
||||||
|
this.users = users;
|
||||||
|
await tags;
|
||||||
|
console.log(`load finished ${whoami} => ${this.whoami}`);
|
||||||
|
this.whoami = whoami;
|
||||||
|
this.loaded = whoami;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_tab() {
|
||||||
|
let following = this.following;
|
||||||
|
let users = this.users;
|
||||||
|
if (this.tab === 'news') {
|
||||||
|
return html`
|
||||||
|
<tf-tab-news id="tf-tab-news" .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news>
|
||||||
|
`;
|
||||||
|
} else if (this.tab === 'connections') {
|
||||||
|
return html`
|
||||||
|
<tf-tab-connections .users=${this.users} .connections=${this.connections} .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 .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#q=') ? decodeURIComponent(this.hash.substring(3)) : 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') {
|
||||||
|
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=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let self = this;
|
||||||
|
|
||||||
|
if (!this.loading && this.whoami && this.loaded !== this.whoami) {
|
||||||
|
this.loading = true;
|
||||||
|
this.load().finally(function() {
|
||||||
|
self.loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabs = html`
|
||||||
|
<div>
|
||||||
|
<input type="button" class="tab" value="News" ?disabled=${self.tab == 'news'} @click=${() => self.set_tab('news')}></input>
|
||||||
|
<input type="button" class="tab" value="Connections" ?disabled=${self.tab == 'connections'} @click=${() => self.set_tab('connections')}></input>
|
||||||
|
<input type="button" class="tab" value="Mentions" ?disabled=${self.tab == 'mentions'} @click=${() => self.set_tab('mentions')}></input>
|
||||||
|
<input type="button" class="tab" value="Search" ?disabled=${self.tab == 'search'} @click=${() => self.set_tab('search')}></input>
|
||||||
|
<input type="button" class="tab" value="Query" ?disabled=${self.tab == 'query'} @click=${() => self.set_tab('query')}></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
let contents =
|
||||||
|
!this.loaded ?
|
||||||
|
this.loading ?
|
||||||
|
html`<div>Loading...</div>` :
|
||||||
|
html`<div>Select or create an identity.</div>` :
|
||||||
|
this.render_tab();
|
||||||
|
return html`
|
||||||
|
${this.render_id_picker()}
|
||||||
|
${tabs}
|
||||||
|
${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)}
|
||||||
|
${contents}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-app', TfElement);
|
484
apps/ssb/tf-compose.js
Normal file
484
apps/ssb/tf-compose.js
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
import {LitElement, html, unsafeHTML} 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 Tribute from './tribute.esm.js';
|
||||||
|
|
||||||
|
class TfComposeElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
root: {type: String},
|
||||||
|
branch: {type: String},
|
||||||
|
apps: {type: Object},
|
||||||
|
drafts: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.users = {};
|
||||||
|
this.root = undefined;
|
||||||
|
this.branch = undefined;
|
||||||
|
this.apps = undefined;
|
||||||
|
this.drafts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
process_text(text) {
|
||||||
|
if (!text) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
/* Update mentions. */
|
||||||
|
let draft = this.get_draft();
|
||||||
|
let updated = false;
|
||||||
|
for (let match of text.matchAll(/\[([^\[]+)]\(([@&%][^\)]+)/g)) {
|
||||||
|
let name = match[1];
|
||||||
|
let link = match[2];
|
||||||
|
let balance = 0;
|
||||||
|
let bracket_end = match.index + match[1].length + '[]'.length - 1;
|
||||||
|
for (let i = bracket_end; i >= 0; i--) {
|
||||||
|
if (text.charAt(i) == ']') {
|
||||||
|
balance++;
|
||||||
|
} else if (text.charAt(i) == '[') {
|
||||||
|
balance--;
|
||||||
|
}
|
||||||
|
if (balance <= 0) {
|
||||||
|
name = text.substring(i + 1, bracket_end);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!draft.mentions) {
|
||||||
|
draft.mentions = {};
|
||||||
|
}
|
||||||
|
if (!draft.mentions[link]) {
|
||||||
|
draft.mentions[link] = {
|
||||||
|
link: link,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
return tfutils.markdown(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input(event) {
|
||||||
|
let edit = this.renderRoot.getElementById('edit');
|
||||||
|
let preview = this.renderRoot.getElementById('preview');
|
||||||
|
preview.innerHTML = this.process_text(edit.value);
|
||||||
|
let content_warning = this.renderRoot.getElementById('content_warning');
|
||||||
|
let content_warning_preview = this.renderRoot.getElementById('content_warning_preview');
|
||||||
|
if (content_warning && content_warning_preview) {
|
||||||
|
content_warning_preview.innerText = content_warning.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(draft) {
|
||||||
|
this.dispatchEvent(new CustomEvent('tf-draft', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
detail: {
|
||||||
|
id: this.branch,
|
||||||
|
draft: draft
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
change() {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
draft.text = this.renderRoot.getElementById('edit')?.value;
|
||||||
|
draft.content_warning = this.renderRoot.getElementById('content_warning')?.value;
|
||||||
|
this.notify(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
convert_to_format(buffer, type, mime_type) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
let img = new Image();
|
||||||
|
img.onload = function() {
|
||||||
|
let canvas = document.createElement('canvas');
|
||||||
|
let width_scale = Math.min(img.width, 1024) / img.width;
|
||||||
|
let height_scale = Math.min(img.height, 1024) / img.height;
|
||||||
|
let scale = Math.min(width_scale, height_scale);
|
||||||
|
canvas.width = img.width * scale;
|
||||||
|
canvas.height = img.height * scale;
|
||||||
|
let context = canvas.getContext('2d');
|
||||||
|
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
let data_url = canvas.toDataURL(mime_type);
|
||||||
|
let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0));
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
img.onerror = function(event) {
|
||||||
|
reject(new Error('Failed to load image.'));
|
||||||
|
};
|
||||||
|
let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join('');
|
||||||
|
let original = `data:${type};base64,${btoa(raw)}`;
|
||||||
|
img.src = original;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async add_file(file) {
|
||||||
|
try {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
let self = this;
|
||||||
|
let buffer = await file.arrayBuffer();
|
||||||
|
let type = file.type;
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
let best_buffer;
|
||||||
|
let best_type;
|
||||||
|
for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
|
||||||
|
let test_buffer = await self.convert_to_format(buffer, file.type, format);
|
||||||
|
if (!best_buffer || test_buffer.length < best_buffer.length) {
|
||||||
|
best_buffer = test_buffer;
|
||||||
|
best_type = format;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buffer = best_buffer;
|
||||||
|
type = best_type;
|
||||||
|
} else {
|
||||||
|
buffer = Array.from(new Uint8Array(buffer));
|
||||||
|
}
|
||||||
|
let id = await tfrpc.rpc.store_blob(buffer);
|
||||||
|
let name = type.split('/')[0] + ':' + file.name;
|
||||||
|
if (!draft.mentions) {
|
||||||
|
draft.mentions = {};
|
||||||
|
}
|
||||||
|
draft.mentions[id] = {
|
||||||
|
link: id,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
size: buffer.length ?? buffer.byteLength,
|
||||||
|
};
|
||||||
|
let edit = self.renderRoot.getElementById('edit');
|
||||||
|
edit.value += `\n`;
|
||||||
|
self.change();
|
||||||
|
self.input();
|
||||||
|
} catch(e) {
|
||||||
|
alert(e?.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paste(event) {
|
||||||
|
let self = this;
|
||||||
|
for (let item of event.clipboardData.items) {
|
||||||
|
if (item.type?.startsWith('image/')) {
|
||||||
|
let file = item.getAsFile();
|
||||||
|
if (!file) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.add_file(file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
let self = this;
|
||||||
|
let draft = this.get_draft();
|
||||||
|
let edit = this.renderRoot.getElementById('edit');
|
||||||
|
let message = {
|
||||||
|
type: 'post',
|
||||||
|
text: edit.value,
|
||||||
|
};
|
||||||
|
if (this.root || this.branch) {
|
||||||
|
message.root = this.root;
|
||||||
|
message.branch = this.branch;
|
||||||
|
}
|
||||||
|
if (Object.values(draft.mentions || {}).length) {
|
||||||
|
message.mentions = Object.values(draft.mentions);
|
||||||
|
}
|
||||||
|
if (draft.content_warning !== undefined) {
|
||||||
|
message.contentWarning = draft.content_warning;
|
||||||
|
}
|
||||||
|
console.log('Would post:', message);
|
||||||
|
if (draft.encrypt_to) {
|
||||||
|
let to = new Set(draft.encrypt_to);
|
||||||
|
to.add(this.whoami);
|
||||||
|
to = [...to];
|
||||||
|
message.recps = to;
|
||||||
|
console.log('message is now', message);
|
||||||
|
message = await tfrpc.rpc.encrypt(this.whoami, to, JSON.stringify(message));
|
||||||
|
console.log('encrypted as', message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
|
||||||
|
edit.value = '';
|
||||||
|
self.change();
|
||||||
|
self.notify(undefined);
|
||||||
|
self.requestUpdate();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
discard() {
|
||||||
|
let edit = this.renderRoot.getElementById('edit');
|
||||||
|
edit.value = '';
|
||||||
|
this.change();
|
||||||
|
let preview = this.renderRoot.getElementById('preview');
|
||||||
|
preview.innerHTML = '';
|
||||||
|
this.notify(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
attach() {
|
||||||
|
let self = this;
|
||||||
|
let edit = this.renderRoot.getElementById('edit');
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.onchange = function(event) {
|
||||||
|
let file = event.target.files[0];
|
||||||
|
self.add_file(file);
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async autocomplete(text, callback) {
|
||||||
|
this.last_autocomplete = text;
|
||||||
|
let results = [];
|
||||||
|
try {
|
||||||
|
let rows = await tfrpc.rpc.query(`
|
||||||
|
SELECT messages.content FROM messages_fts(?)
|
||||||
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||||
|
WHERE messages.content LIKE ?
|
||||||
|
ORDER BY timestamp DESC LIMIT 10
|
||||||
|
`, ['"' + text.replace('"', '""') + '"', `%%`]);
|
||||||
|
for (let row of rows) {
|
||||||
|
for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) {
|
||||||
|
if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) {
|
||||||
|
results.push({key: match[1], value: match[2]});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (this.last_autocomplete === text) {
|
||||||
|
callback(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUpdated() {
|
||||||
|
let tribute = new Tribute({
|
||||||
|
collection: [
|
||||||
|
{
|
||||||
|
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||||
|
selectTemplate: function(item) {
|
||||||
|
return `[@${item.original.key}](${item.original.value})`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: '&',
|
||||||
|
values: this.autocomplete,
|
||||||
|
selectTemplate: function(item) {
|
||||||
|
return ``;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tribute.attach(this.renderRoot.getElementById('edit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
super.updated();
|
||||||
|
let edit = this.renderRoot.getElementById('edit');
|
||||||
|
if (this.last_updated_text !== edit.value) {
|
||||||
|
let preview = this.renderRoot.getElementById('preview');
|
||||||
|
preview.innerHTML = this.process_text(edit.value);
|
||||||
|
this.last_updated_text = edit.value;
|
||||||
|
}
|
||||||
|
let encrypt = this.renderRoot.getElementById('encrypt_to');
|
||||||
|
if (encrypt) {
|
||||||
|
let tribute = new Tribute({
|
||||||
|
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||||
|
selectTemplate: function(item) {
|
||||||
|
return item.original.value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tribute.attach(encrypt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove_mention(id) {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
delete draft.mentions[id];
|
||||||
|
this.notify(draft);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render_mention(mention) {
|
||||||
|
let self = this;
|
||||||
|
return html`
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<div style="align-self: center; margin: 0.5em">
|
||||||
|
<input type="button" value="🚮" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}></input>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column">
|
||||||
|
<h3>${mention.name}</h3>
|
||||||
|
<div style="padding-left: 1em">
|
||||||
|
${Object.entries(mention)
|
||||||
|
.filter(x => x[0] != 'name')
|
||||||
|
.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_attach_app() {
|
||||||
|
let self = this;
|
||||||
|
|
||||||
|
async function attach_selected_app() {
|
||||||
|
let name = self.renderRoot.getElementById('select').value;
|
||||||
|
let id = self.apps[name];
|
||||||
|
let mentions = {};
|
||||||
|
mentions[id] = {
|
||||||
|
name: name,
|
||||||
|
link: id,
|
||||||
|
type: 'application/tildefriends',
|
||||||
|
};
|
||||||
|
if (name && id) {
|
||||||
|
let app = JSON.parse(await tfrpc.rpc.get_blob(id));
|
||||||
|
for (let entry of Object.entries(app.files)) {
|
||||||
|
mentions[entry[1]] = {
|
||||||
|
name: entry[0],
|
||||||
|
link: entry[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let draft = self.get_draft();
|
||||||
|
draft.mentions = Object.assign(draft.mentions || {}, mentions);
|
||||||
|
self.requestUpdate();
|
||||||
|
self.notify(draft);
|
||||||
|
self.apps = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.apps) {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<select id="select">
|
||||||
|
${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)}
|
||||||
|
</select>
|
||||||
|
<input type="button" value="Attach" @click=${attach_selected_app}></input>
|
||||||
|
<input type="button" value="Cancel" @click=${() => this.apps = null}></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_attach_app_button() {
|
||||||
|
let self = this;
|
||||||
|
async function attach_app() {
|
||||||
|
self.apps = await tfrpc.rpc.apps();
|
||||||
|
}
|
||||||
|
if (!this.apps) {
|
||||||
|
return html`<input type="button" value="Attach App" @click=${attach_app}></input>`;
|
||||||
|
} else {
|
||||||
|
return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_content_warning(value) {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
draft.content_warning = value;
|
||||||
|
this.notify(draft);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render_content_warning() {
|
||||||
|
let self = this;
|
||||||
|
let draft = this.get_draft();
|
||||||
|
if (draft.content_warning !== undefined) {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" id="cw" @change=${() => self.set_content_warning(undefined)} checked></input>
|
||||||
|
<label for="cw">CW</label>
|
||||||
|
<input type="text" id="content_warning" @input=${this.input} @change=${this.change} value=${draft.content_warning}></input>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return html`
|
||||||
|
<input type="checkbox" id="cw" @change=${() => self.set_content_warning('')}></input>
|
||||||
|
<label for="cw">CW</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get_draft() {
|
||||||
|
return this.drafts[this.branch || ''] || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
update_encrypt(event) {
|
||||||
|
let input = event.srcElement;
|
||||||
|
let matches = input.value.match(/@.*?\.ed25519/g);
|
||||||
|
if (matches) {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
let to = [...new Set(matches.concat(draft.encrypt_to))];
|
||||||
|
this.set_encrypt(to);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_encrypt() {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
if (draft.encrypt_to === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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="button" value="🚮" @click=${() => this.set_encrypt(undefined)}></input>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
${draft.encrypt_to.map(x => html`
|
||||||
|
<li>
|
||||||
|
<tf-user id=${x} .users=${this.users}></tf-user>
|
||||||
|
<input type="button" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter(id => id != x))}></input>
|
||||||
|
</li>`)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_encrypt(encrypt) {
|
||||||
|
let draft = this.get_draft();
|
||||||
|
draft.encrypt_to = encrypt;
|
||||||
|
this.notify(draft);
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let self = this;
|
||||||
|
let draft = self.get_draft();
|
||||||
|
let content_warning =
|
||||||
|
draft.content_warning !== undefined ?
|
||||||
|
html`<div id="content_warning_preview" class="content_warning">${draft.content_warning}</div>` :
|
||||||
|
undefined;
|
||||||
|
let encrypt = draft.encrypt_to !== undefined ?
|
||||||
|
undefined :
|
||||||
|
html`<input type="button" value="🔐" @click=${() => this.set_encrypt([])}></input>`;
|
||||||
|
let result = html`
|
||||||
|
${this.render_encrypt()}
|
||||||
|
<div style="display: flex; flex-direction: row; width: 100%">
|
||||||
|
<textarea id="edit" @input=${this.input} @change=${this.change} @paste=${this.paste} style="flex: 1 0 50%">${draft.text}</textarea>
|
||||||
|
<div style="flex: 1 0 50%">
|
||||||
|
${content_warning}
|
||||||
|
<div id="preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
|
||||||
|
${this.render_content_warning()}
|
||||||
|
${this.render_attach_app()}
|
||||||
|
<input type="button" id="submit" value="Submit" @click=${this.submit}></input>
|
||||||
|
<input type="button" value="Attach" @click=${this.attach}></input>
|
||||||
|
${this.render_attach_app_button()}
|
||||||
|
${encrypt}
|
||||||
|
<input type="button" value="Discard" @click=${this.discard}></input>
|
||||||
|
`;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-compose', TfComposeElement);
|
36
apps/ssb/tf-id-picker.js
Normal file
36
apps/ssb/tf-id-picker.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {LitElement, html} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Provide a list of IDs, and this lets the user pick one.
|
||||||
|
*/
|
||||||
|
class TfIdentityPickerElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
ids: {type: Array},
|
||||||
|
selected: {type: String},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.ids = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
changed(event) {
|
||||||
|
this.selected = event.srcElement.value;
|
||||||
|
this.dispatchEvent(new Event('change', {
|
||||||
|
srcElement: this,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`
|
||||||
|
<select @change=${this.changed} style="max-width: 100%">
|
||||||
|
${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-id-picker', TfIdentityPickerElement);
|
542
apps/ssb/tf-message.js
Normal file
542
apps/ssb/tf-message.js
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import * as tfutils from './tf-utils.js';
|
||||||
|
import * as emojis from './emojis.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfMessageElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
message: {type: Object},
|
||||||
|
users: {type: Object},
|
||||||
|
drafts: {type: Object},
|
||||||
|
format: {type: String},
|
||||||
|
blog_data: {type: String},
|
||||||
|
expanded: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.whoami = null;
|
||||||
|
this.message = {};
|
||||||
|
this.users = {};
|
||||||
|
this.drafts = {};
|
||||||
|
this.format = 'message';
|
||||||
|
this.expanded = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
show_reply() {
|
||||||
|
let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: {
|
||||||
|
encrypt_to: this.message?.decrypted?.recps,
|
||||||
|
}}});
|
||||||
|
this.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
discard_reply() {
|
||||||
|
this.dispatchEvent(new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.id, draft: undefined}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
render_votes() {
|
||||||
|
function normalize_expression(expression) {
|
||||||
|
if (expression === 'Like' || !expression) {
|
||||||
|
return '👍';
|
||||||
|
} else if (expression === 'Unlike') {
|
||||||
|
return '👎';
|
||||||
|
} else if (expression === 'heart') {
|
||||||
|
return '❤️';
|
||||||
|
} else {
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html`<div>${(this.message.votes || []).map(
|
||||||
|
vote => html`
|
||||||
|
<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}">
|
||||||
|
${normalize_expression(vote.content.vote.expression)}
|
||||||
|
</span>
|
||||||
|
`)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_raw() {
|
||||||
|
let raw = {
|
||||||
|
id: this.message?.id,
|
||||||
|
previous: this.message?.previous,
|
||||||
|
author: this.message?.author,
|
||||||
|
sequence: this.message?.sequence,
|
||||||
|
timestamp: this.message?.timestamp,
|
||||||
|
hash: this.message?.hash,
|
||||||
|
content: this.message?.content,
|
||||||
|
signature: this.message?.signature,
|
||||||
|
};
|
||||||
|
return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
vote(emoji) {
|
||||||
|
let reaction = emoji;
|
||||||
|
let message = this.message.id;
|
||||||
|
if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
|
||||||
|
tfrpc.rpc.appendMessage(
|
||||||
|
this.whoami,
|
||||||
|
{
|
||||||
|
type: 'vote',
|
||||||
|
vote: {
|
||||||
|
link: message,
|
||||||
|
value: 1,
|
||||||
|
expression: reaction,
|
||||||
|
},
|
||||||
|
}).catch(function(error) {
|
||||||
|
alert(error?.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
react(event) {
|
||||||
|
emojis.picker(x => this.vote(x));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '100%';
|
||||||
|
img.style.maxHeight = '100%';
|
||||||
|
img.style.display = 'block';
|
||||||
|
img.style.margin = 'auto';
|
||||||
|
img.style.objectFit = 'contain';
|
||||||
|
img.style.width = '100%';
|
||||||
|
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);
|
||||||
|
} else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) {
|
||||||
|
let next = event.srcElement.nextSibling;
|
||||||
|
if (next.style.display == 'block') {
|
||||||
|
next.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
next.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_mention(mention) {
|
||||||
|
if (!mention?.link || typeof(mention.link) != 'string') {
|
||||||
|
return html` <pre>${JSON.stringify(mention)}</pre>`;
|
||||||
|
} else if (mention?.link?.startsWith('&') &&
|
||||||
|
mention?.type?.startsWith('image/')) {
|
||||||
|
return html`
|
||||||
|
<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}>
|
||||||
|
`;
|
||||||
|
} else if (mention.link?.startsWith('&') &&
|
||||||
|
mention.name?.startsWith('audio:')) {
|
||||||
|
return html`
|
||||||
|
<audio controls style="height: 32px">
|
||||||
|
<source src=${'/' + mention.link + '/view'}></source>
|
||||||
|
</audio>
|
||||||
|
`;
|
||||||
|
} else if (mention.link?.startsWith('&') &&
|
||||||
|
mention.name?.startsWith('video:')) {
|
||||||
|
return html`
|
||||||
|
<video controls style="max-height: 240px; max-width: 128px">
|
||||||
|
<source src=${'/' + mention.link + '/view'}></source>
|
||||||
|
</video>
|
||||||
|
`;
|
||||||
|
} else if (mention.link?.startsWith('&') &&
|
||||||
|
mention?.type === 'application/tildefriends') {
|
||||||
|
return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`;
|
||||||
|
} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) {
|
||||||
|
return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`;
|
||||||
|
} else if (mention.link?.startsWith('#')) {
|
||||||
|
return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`;
|
||||||
|
} else if (Object.keys(mention).length == 2 && mention.link && mention.name) {
|
||||||
|
return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`;
|
||||||
|
} else {
|
||||||
|
return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_mentions() {
|
||||||
|
let mentions = this.message?.content?.mentions || [];
|
||||||
|
mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1);
|
||||||
|
if (mentions.length) {
|
||||||
|
let self = this;
|
||||||
|
return html`
|
||||||
|
<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black">
|
||||||
|
<legend>Mentions</legend>
|
||||||
|
${mentions.map(x => self.render_mention(x))}
|
||||||
|
</fieldset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total_child_messages(message) {
|
||||||
|
if (!message.child_messages) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let total = message.child_messages.length;
|
||||||
|
for (let m of message.child_messages)
|
||||||
|
{
|
||||||
|
total += this.total_child_messages(m);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_expanded(expanded, tag) {
|
||||||
|
this.dispatchEvent(new CustomEvent('tf-expand', {bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}}));
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle_expanded(tag) {
|
||||||
|
this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_children() {
|
||||||
|
let self = this;
|
||||||
|
if (this.message.child_messages?.length) {
|
||||||
|
if (!this.expanded[this.message.id]) {
|
||||||
|
return html`<input type="button" value=${this.total_child_messages(this.message) + ' More'} @click=${() => self.set_expanded(true)}></input>`;
|
||||||
|
} else {
|
||||||
|
return html`<input type="button" value="Collapse" @click=${() => self.set_expanded(false)}></input>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_channels() {
|
||||||
|
let content = this.message?.content;
|
||||||
|
if (this?.messsage?.decrypted?.type == 'post') {
|
||||||
|
content = this.message.decrypted;
|
||||||
|
}
|
||||||
|
let channels = [];
|
||||||
|
if (typeof content.channel === 'string') {
|
||||||
|
channels.push(`#${content.channel}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(content.mentions)) {
|
||||||
|
for (let mention of content.mentions) {
|
||||||
|
if (typeof mention?.link === 'string' &&
|
||||||
|
mention.link.startsWith('#')) {
|
||||||
|
channels.push(mention.link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return channels.map(x => html`<tf-tag tag=${x}></tf-tag>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let content = this.message?.content;
|
||||||
|
if (this.message?.decrypted?.type == 'post') {
|
||||||
|
content = this.message.decrypted;
|
||||||
|
}
|
||||||
|
let self = this;
|
||||||
|
let raw_button;
|
||||||
|
switch (this.format) {
|
||||||
|
case 'raw':
|
||||||
|
if (content?.type == 'post' || content?.type == 'blog') {
|
||||||
|
raw_button = html`<input type="button" value="Markdown" @click=${() => self.format = 'md'}></input>`;
|
||||||
|
} else {
|
||||||
|
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
raw_button = html`<input type="button" value="Raw" @click=${() => self.format = 'raw'}></input>`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
function small_frame(inner) {
|
||||||
|
let body;
|
||||||
|
return html`
|
||||||
|
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere">
|
||||||
|
<tf-user id=${self.message.author} .users=${self.users}></tf-user>
|
||||||
|
<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
|
||||||
|
${raw_button}
|
||||||
|
${self.format == 'raw' ? self.render_raw() : inner}
|
||||||
|
${self.render_votes()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (this.message?.type === 'contact_group') {
|
||||||
|
return html`
|
||||||
|
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere">
|
||||||
|
${this.message.messages.map(x =>
|
||||||
|
html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`
|
||||||
|
)}
|
||||||
|
</div>`;
|
||||||
|
} else if (this.message.placeholder) {
|
||||||
|
return html`
|
||||||
|
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere">
|
||||||
|
<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder)
|
||||||
|
<div>${this.render_votes()}</div>
|
||||||
|
${(this.message.child_messages || []).map(x => html`
|
||||||
|
<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>
|
||||||
|
`)}
|
||||||
|
</div>`;
|
||||||
|
} else if (typeof(content?.type === 'string')) {
|
||||||
|
if (content.type == 'about') {
|
||||||
|
let name;
|
||||||
|
let image;
|
||||||
|
let description;
|
||||||
|
if (content.name !== undefined) {
|
||||||
|
name = html`<div><b>Name:</b> ${content.name}</div>`;
|
||||||
|
}
|
||||||
|
if (content.image !== undefined) {
|
||||||
|
image = html`
|
||||||
|
<div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (content.description !== undefined) {
|
||||||
|
description = html`
|
||||||
|
<div style="flex: 1 0 50%; overflow-wrap: anywhere">
|
||||||
|
<div>${unsafeHTML(tfutils.markdown(content.description))}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
let update = content.about == this.message.author ?
|
||||||
|
html`<div style="font-weight: bold">Updated profile.</div>` :
|
||||||
|
html`<div style="font-weight: bold">Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>.</div>`;
|
||||||
|
return small_frame(html`
|
||||||
|
${update}
|
||||||
|
${name}
|
||||||
|
${image}
|
||||||
|
${description}
|
||||||
|
`);
|
||||||
|
} else if (content.type == 'contact') {
|
||||||
|
return html`
|
||||||
|
<div>
|
||||||
|
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||||
|
is
|
||||||
|
${
|
||||||
|
content.blocking === true ? 'blocking' :
|
||||||
|
content.blocking === false ? 'no longer blocking' :
|
||||||
|
content.following === true ? 'following' :
|
||||||
|
content.following === false ? 'no longer following' :
|
||||||
|
'?'
|
||||||
|
}
|
||||||
|
<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (content.type == 'post') {
|
||||||
|
let reply = (this.drafts[this.message?.id] !== undefined) ? html`
|
||||||
|
<tf-compose
|
||||||
|
whoami=${this.whoami}
|
||||||
|
.users=${this.users}
|
||||||
|
root=${this.message.content.root || this.message.id}
|
||||||
|
branch=${this.message.id}
|
||||||
|
.drafts=${this.drafts}
|
||||||
|
@tf-discard=${this.discard_reply}></tf-compose>
|
||||||
|
` : html`
|
||||||
|
<input type="button" value="Reply" @click=${this.show_reply}></input>
|
||||||
|
`;
|
||||||
|
let self = this;
|
||||||
|
let body;
|
||||||
|
switch (this.format) {
|
||||||
|
case 'raw':
|
||||||
|
body = this.render_raw();
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`;
|
||||||
|
break;
|
||||||
|
case 'message':
|
||||||
|
body = unsafeHTML(tfutils.markdown(content.text));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let content_warning = html`
|
||||||
|
<div class="content_warning" @click=${x => this.toggle_expanded(':cw')}>${content.contentWarning}</div>
|
||||||
|
`;
|
||||||
|
let content_html =
|
||||||
|
html`
|
||||||
|
${this.render_channels()}
|
||||||
|
<div @click=${this.body_click}>${body}</div>
|
||||||
|
${this.render_mentions()}
|
||||||
|
`;
|
||||||
|
let payload =
|
||||||
|
content.contentWarning ?
|
||||||
|
self.expanded[(this.message.id || '') + ':cw'] ?
|
||||||
|
html`
|
||||||
|
${content_warning}
|
||||||
|
${content_html}
|
||||||
|
` :
|
||||||
|
content_warning :
|
||||||
|
content_html;
|
||||||
|
let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
|
||||||
|
let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px">
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||||
|
${is_encrypted}
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||||
|
<span>${raw_button}</span>
|
||||||
|
</div>
|
||||||
|
${payload}
|
||||||
|
${this.render_votes()}
|
||||||
|
<div>
|
||||||
|
${reply}
|
||||||
|
<input type="button" value="React" @click=${this.react}></input>
|
||||||
|
</div>
|
||||||
|
${this.render_children()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (content.type === 'issue') {
|
||||||
|
let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
|
||||||
|
let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px">
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||||
|
${is_encrypted}
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||||
|
<span>${raw_button}</span>
|
||||||
|
</div>
|
||||||
|
${content.text}
|
||||||
|
${this.render_votes()}
|
||||||
|
<div>
|
||||||
|
<input type="button" value="React" @click=${this.react}></input>
|
||||||
|
</div>
|
||||||
|
${this.render_children()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (content.type === 'blog') {
|
||||||
|
let self = this;
|
||||||
|
tfrpc.rpc.get_blob(content.blog).then(function(data) {
|
||||||
|
self.blog_data = data;
|
||||||
|
});
|
||||||
|
let payload =
|
||||||
|
this.expanded[(this.message.id || '') + ':blog'] ?
|
||||||
|
html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
|
||||||
|
undefined;
|
||||||
|
let body;
|
||||||
|
switch (this.format) {
|
||||||
|
case 'raw':
|
||||||
|
body = this.render_raw();
|
||||||
|
break;
|
||||||
|
case 'md':
|
||||||
|
body = content.summary;
|
||||||
|
break;
|
||||||
|
case 'message':
|
||||||
|
body = html`
|
||||||
|
<div
|
||||||
|
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
|
||||||
|
@click=${x => self.toggle_expanded(':blog')}>
|
||||||
|
<h2>${content.title}</h2>
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<img src=/${content.thumbnail}/view></img>
|
||||||
|
<span>${content.summary}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${payload}
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
code {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
|
||||||
|
<div style="display: flex; flex-direction: row">
|
||||||
|
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||||
|
<span style="flex: 1"></span>
|
||||||
|
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||||
|
<span>${raw_button}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>${body}</div>
|
||||||
|
${this.render_mentions()}
|
||||||
|
${this.render_votes()}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (content.type === 'pub') {
|
||||||
|
return small_frame(html`
|
||||||
|
<style>
|
||||||
|
span {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<span>
|
||||||
|
<div>
|
||||||
|
🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user>
|
||||||
|
</div>
|
||||||
|
<pre>${content.address.host}:${content.address.port}</pre>
|
||||||
|
</span>`);
|
||||||
|
} else if (content.type === 'channel') {
|
||||||
|
return small_frame(html`
|
||||||
|
<div>
|
||||||
|
${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
} else if (typeof(this.message.content) == 'string') {
|
||||||
|
if (this.message?.decrypted) {
|
||||||
|
return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.decrypted, null, 2)}</pre>`);
|
||||||
|
} else {
|
||||||
|
return small_frame(html`<span>🔒</span>`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return small_frame(html`<div><b>type</b>: ${content.type}</div>`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return small_frame(this.render_raw());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-message', TfMessageElement);
|
183
apps/ssb/tf-news.js
Normal file
183
apps/ssb/tf-news.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfNewsElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
messages: {type: Array},
|
||||||
|
following: {type: Array},
|
||||||
|
drafts: {type: Object},
|
||||||
|
expanded: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.whoami = null;
|
||||||
|
this.users = {};
|
||||||
|
this.messages = [];
|
||||||
|
this.following = [];
|
||||||
|
this.drafts = {};
|
||||||
|
this.expanded = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
process_messages(messages) {
|
||||||
|
let self = this;
|
||||||
|
let messages_by_id = {};
|
||||||
|
|
||||||
|
console.log('processing', messages.length, 'messages');
|
||||||
|
|
||||||
|
function ensure_message(id) {
|
||||||
|
let found = messages_by_id[id];
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
} else {
|
||||||
|
let added = {
|
||||||
|
id: id,
|
||||||
|
placeholder: true,
|
||||||
|
content: '"placeholder"',
|
||||||
|
parent_message: undefined,
|
||||||
|
child_messages: [],
|
||||||
|
votes: [],
|
||||||
|
};
|
||||||
|
messages_by_id[id] = added;
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function link_message(message) {
|
||||||
|
if (message.content.type === 'vote') {
|
||||||
|
let parent = ensure_message(message.content.vote.link);
|
||||||
|
if (!parent.votes) {
|
||||||
|
parent.votes = [];
|
||||||
|
}
|
||||||
|
parent.votes.push(message);
|
||||||
|
message.parent_message = message.content.vote.link;
|
||||||
|
} else if (message.content.type == 'post') {
|
||||||
|
if (message.content.root) {
|
||||||
|
if (typeof(message.content.root) === 'string') {
|
||||||
|
let m = ensure_message(message.content.root);
|
||||||
|
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]);
|
||||||
|
if (!m.child_messages) {
|
||||||
|
m.child_messages = [];
|
||||||
|
}
|
||||||
|
m.child_messages.push(message);
|
||||||
|
message.parent_message = message.content.root[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let message of messages) {
|
||||||
|
message.votes = [];
|
||||||
|
message.parent_message = undefined;
|
||||||
|
message.child_messages = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let message of messages) {
|
||||||
|
try {
|
||||||
|
message.content = JSON.parse(message.content);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (!messages_by_id[message.id]) {
|
||||||
|
messages_by_id[message.id] = message;
|
||||||
|
link_message(message);
|
||||||
|
} else if (messages_by_id[message.id].placeholder) {
|
||||||
|
let placeholder = messages_by_id[message.id];
|
||||||
|
messages_by_id[message.id] = message;
|
||||||
|
message.parent_message = placeholder.parent_message;
|
||||||
|
message.child_messages = placeholder.child_messages;
|
||||||
|
message.votes = placeholder.votes;
|
||||||
|
if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) {
|
||||||
|
let children = messages_by_id[placeholder.parent_message].child_messages;
|
||||||
|
children.splice(children.indexOf(placeholder), 1);
|
||||||
|
children.push(message);
|
||||||
|
}
|
||||||
|
link_message(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages_by_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_latest_subtree_timestamp(messages) {
|
||||||
|
let latest = 0;
|
||||||
|
for (let message of messages || []) {
|
||||||
|
if (message.latest_subtree_timestamp === undefined) {
|
||||||
|
message.latest_subtree_timestamp = Math.max(message.timestamp ?? 0, this.update_latest_subtree_timestamp(message.child_messages));
|
||||||
|
}
|
||||||
|
latest = Math.max(latest, message.latest_subtree_timestamp);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
finalize_messages(messages_by_id) {
|
||||||
|
function recursive_sort(messages, top) {
|
||||||
|
if (messages) {
|
||||||
|
if (top) {
|
||||||
|
messages.sort((a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp);
|
||||||
|
} else {
|
||||||
|
messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
}
|
||||||
|
for (let message of messages) {
|
||||||
|
recursive_sort(message.child_messages, false);
|
||||||
|
}
|
||||||
|
return messages.map(x => Object.assign({}, x));
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let roots = Object.values(messages_by_id).filter(x => !x.parent_message);
|
||||||
|
this.update_latest_subtree_timestamp(roots);
|
||||||
|
return recursive_sort(roots, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
group_following(messages) {
|
||||||
|
let result = [];
|
||||||
|
let group = [];
|
||||||
|
for (let message of messages) {
|
||||||
|
if (message?.content?.type === 'contact') {
|
||||||
|
group.push(message);
|
||||||
|
} else {
|
||||||
|
if (group.length > 0) {
|
||||||
|
result.push({
|
||||||
|
type: 'contact_group',
|
||||||
|
messages: group,
|
||||||
|
});
|
||||||
|
group = [];
|
||||||
|
}
|
||||||
|
result.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
load_and_render(messages) {
|
||||||
|
let messages_by_id = this.process_messages(messages);
|
||||||
|
let final_messages = this.group_following(this.finalize_messages(messages_by_id));
|
||||||
|
return html`
|
||||||
|
<div style="display: flex; flex-direction: column">
|
||||||
|
${final_messages.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} collapsed=true></tf-message>`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return this.load_and_render(this.messages || []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-news', TfNewsElement);
|
184
apps/ssb/tf-profile.js
Normal file
184
apps/ssb/tf-profile.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import {LitElement, html, 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';
|
||||||
|
|
||||||
|
class TfProfileElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
editing: {type: Object},
|
||||||
|
whoami: {type: String},
|
||||||
|
id: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
size: {type: Number},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.editing = null;
|
||||||
|
this.whoami = null;
|
||||||
|
this.id = null;
|
||||||
|
this.users = {};
|
||||||
|
this.size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
modify(change) {
|
||||||
|
tfrpc.rpc.appendMessage(this.whoami,
|
||||||
|
Object.assign({
|
||||||
|
type: 'contact',
|
||||||
|
contact: this.id,
|
||||||
|
}, change)).catch(function(error) {
|
||||||
|
alert(error?.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
follow() {
|
||||||
|
this.modify({following: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
unfollow() {
|
||||||
|
this.modify({following: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
block() {
|
||||||
|
this.modify({blocking: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
unblock() {
|
||||||
|
this.modify({blocking: false});
|
||||||
|
}
|
||||||
|
|
||||||
|
edit() {
|
||||||
|
let original = this.users[this.id];
|
||||||
|
this.editing = {
|
||||||
|
name: original.name,
|
||||||
|
description: original.description,
|
||||||
|
image: original.image,
|
||||||
|
};
|
||||||
|
console.log(this.editing);
|
||||||
|
}
|
||||||
|
|
||||||
|
save_edits() {
|
||||||
|
let self = this;
|
||||||
|
let message = {
|
||||||
|
type: 'about',
|
||||||
|
about: this.whoami,
|
||||||
|
};
|
||||||
|
for (let key of Object.keys(this.editing)) {
|
||||||
|
if (this.editing[key] !== this.users[this.id][key]) {
|
||||||
|
message[key] = this.editing[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
|
||||||
|
self.editing = null;
|
||||||
|
}).catch(function(error) {
|
||||||
|
alert(error?.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
discard_edits() {
|
||||||
|
this.editing = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
attach_image() {
|
||||||
|
let self = this;
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.onchange = function(event) {
|
||||||
|
let file = event.target.files[0];
|
||||||
|
file.arrayBuffer().then(function(buffer) {
|
||||||
|
let bin = Array.from(new Uint8Array(buffer));
|
||||||
|
return tfrpc.rpc.store_blob(bin);
|
||||||
|
}).then(function(id) {
|
||||||
|
self.editing = Object.assign({}, self.editing, {image: id});
|
||||||
|
console.log(self.editing);
|
||||||
|
}).catch(function(e) {
|
||||||
|
alert(e.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let self = this;
|
||||||
|
let profile = this.users[this.id] || {};
|
||||||
|
tfrpc.rpc.query(
|
||||||
|
`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
|
||||||
|
[this.id]).then(function(result) {
|
||||||
|
self.size = result[0].size;
|
||||||
|
});
|
||||||
|
let edit;
|
||||||
|
let follow;
|
||||||
|
let block;
|
||||||
|
if (this.id === this.whoami) {
|
||||||
|
if (this.editing) {
|
||||||
|
edit = html`
|
||||||
|
<input type="button" value="Save Profile" @click=${this.save_edits}></input>
|
||||||
|
<input type="button" value="Discard" @click=${this.discard_edits}></input>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
edit = html`<input type="button" value="Edit Profile" @click=${this.edit}></input>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.id !== this.whoami &&
|
||||||
|
this.users[this.whoami]?.following) {
|
||||||
|
follow =
|
||||||
|
this.users[this.whoami].following[this.id] ?
|
||||||
|
html`<input type="button" value="Unfollow" @click=${this.unfollow}></input>` :
|
||||||
|
html`<input type="button" value="Follow" @click=${this.follow}></input>`;
|
||||||
|
}
|
||||||
|
if (this.id !== this.whoami &&
|
||||||
|
this.users[this.whoami]?.blocking) {
|
||||||
|
block =
|
||||||
|
this.users[this.whoami].blocking[this.id] ?
|
||||||
|
html`<input type="button" value="Unblock" @click=${this.unblock}></input>` :
|
||||||
|
html`<input type="button" value="Block" @click=${this.block}></input>`;
|
||||||
|
}
|
||||||
|
let edit_profile = this.editing ? html`
|
||||||
|
<div style="flex: 1 0 50%">
|
||||||
|
<div>
|
||||||
|
<label for="name">Name:</label>
|
||||||
|
<input type="text" id="name" value=${this.editing.name} @input=${event => this.editing = Object.assign({}, this.editing, {name: event.srcElement.value})}></input>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div><label for="description">Description:</label></div>
|
||||||
|
<textarea id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="public_web_hosting">Public Web Hosting:</label>
|
||||||
|
<input type="checkbox" id="public_web_hosting" value=${this.editing.public_web_hosting} @input=${event => this.editing = Object.assign({}, this.editing, {publicWebHosting: event.srcElement.checked})}></input>
|
||||||
|
</div>
|
||||||
|
<input type="button" value="Attach Image" @click=${this.attach_image}></input>
|
||||||
|
</div>` : null;
|
||||||
|
let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link;
|
||||||
|
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 style="display: flex; flex-direction: row">
|
||||||
|
${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 ${Object.keys(profile.following || {}).length} identities.
|
||||||
|
Followed by ${Object.values(self.users).filter(x => (x.following || {})[self.id]).length} identities.
|
||||||
|
Blocking ${Object.keys(profile.blocking || {}).length} identities.
|
||||||
|
Blocked by ${Object.values(self.users).filter(x => (x.blocking || {})[self.id]).length} identities.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
${edit}
|
||||||
|
${follow}
|
||||||
|
${block}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-profile', TfProfileElement);
|
54
apps/ssb/tf-styles.js
Normal file
54
apps/ssb/tf-styles.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import {css} from './lit-all.min.js';
|
||||||
|
|
||||||
|
export let styles = css`
|
||||||
|
a:link {
|
||||||
|
color: #bbf;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #ddf;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: min(640px, 100%);
|
||||||
|
max-height: min(480px, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 0;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:disabled {
|
||||||
|
color: #088;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content_warning {
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 1em;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.img_caption {
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.img_caption::after {
|
||||||
|
content: ' ±';
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #fff;
|
||||||
|
margin-left: 0px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
`;
|
127
apps/ssb/tf-tab-connections.js
Normal file
127
apps/ssb/tf-tab-connections.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import {LitElement, html} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
|
||||||
|
class TfTabConnectionsElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
broadcasts: {type: Array},
|
||||||
|
identities: {type: Array},
|
||||||
|
connections: {type: Array},
|
||||||
|
stored_connections: {type: Array},
|
||||||
|
users: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.broadcasts = [];
|
||||||
|
this.identities = [];
|
||||||
|
this.connections = [];
|
||||||
|
this.stored_connections = [];
|
||||||
|
this.users = {};
|
||||||
|
tfrpc.rpc.getAllIdentities().then(function(identities) {
|
||||||
|
self.identities = identities || [];
|
||||||
|
});
|
||||||
|
tfrpc.rpc.getStoredConnections().then(function(connections) {
|
||||||
|
self.stored_connections = connections || [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render_connection_summary(connection) {
|
||||||
|
if (connection.address && connection.port) {
|
||||||
|
return html`(<small>${connection.address}:${connection.port}</small>)`;
|
||||||
|
} else if (connection.tunnel) {
|
||||||
|
return html`(room peer)`;
|
||||||
|
} else {
|
||||||
|
return JSON.stringify(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render_room_peers(connection) {
|
||||||
|
let self = this;
|
||||||
|
let peers = this.broadcasts.filter(x => x.tunnel?.id == connection);
|
||||||
|
if (peers.length) {
|
||||||
|
let connections = this.connections.map(x => x.id);
|
||||||
|
return html`${peers.filter(x => connections.indexOf(x.pubkey) == -1).map(x => html`${self.render_room_peer(x)}`)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _tunnel(portal, target) {
|
||||||
|
return tfrpc.rpc.createTunnel(portal, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
render_room_peer(connection) {
|
||||||
|
let self = this;
|
||||||
|
return html`
|
||||||
|
<li>
|
||||||
|
<input type="button" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)} value="Connect"></input>
|
||||||
|
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render_broadcast(connection) {
|
||||||
|
return html`
|
||||||
|
<li>
|
||||||
|
<input type="button" @click=${() => tfrpc.rpc.connect(connection)} value="Connect"></input>
|
||||||
|
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
|
||||||
|
${this.render_connection_summary(connection)}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async forget_stored_connection(connection) {
|
||||||
|
await tfrpc.rpc.forgetStoredConnection(connection);
|
||||||
|
this.stored_connections = (await tfrpc.rpc.getStoredConnections()) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
render_connection(connection) {
|
||||||
|
return html`
|
||||||
|
<input type="button" @click=${() => tfrpc.rpc.closeConnection(connection.id)} value="Close"></input>
|
||||||
|
<tf-user id=${connection.id} .users=${this.users}></tf-user>
|
||||||
|
${connection.tunnel !== undefined ? '🚇' : html`(${connection.host}:${connection.port})`}
|
||||||
|
<ul>
|
||||||
|
${this.connections.filter(x => x.tunnel === this.connections.indexOf(connection)).map(x => html`<li>${this.render_connection(x)}</li>`)}
|
||||||
|
${this.render_room_peers(connection.id)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let self = this;
|
||||||
|
return html`
|
||||||
|
<h2>New Connection</h2>
|
||||||
|
<div style="display: flex; flex-direction: column">
|
||||||
|
<textarea id="code"></textarea>
|
||||||
|
</div>
|
||||||
|
<input type="button" @click=${() => tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)} value="Connect"></input>
|
||||||
|
<h2>Broadcasts</h2>
|
||||||
|
<ul>
|
||||||
|
${this.broadcasts.filter(x => x.address).map(x => self.render_broadcast(x))}
|
||||||
|
</ul>
|
||||||
|
<h2>Connections</h2>
|
||||||
|
<ul>
|
||||||
|
${this.connections.filter(x => x.tunnel === undefined).map(x => html`
|
||||||
|
<li>${this.render_connection(x)}</li>
|
||||||
|
`)}
|
||||||
|
</ul>
|
||||||
|
<h2>Stored Connections (WIP)</h2>
|
||||||
|
<ul>
|
||||||
|
${this.stored_connections.map(x => html`
|
||||||
|
<li>
|
||||||
|
<input type="button" @click=${() => self.forget_stored_connection(x)} value="Forget"></input>
|
||||||
|
<input type="button" @click=${() => tfrpc.rpc.connect(x)} value="Connect"></input>
|
||||||
|
${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user>
|
||||||
|
</li>
|
||||||
|
`)}
|
||||||
|
</ul>
|
||||||
|
<h2>Local Accounts</h2>
|
||||||
|
<ul>
|
||||||
|
${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-tab-connections', TfTabConnectionsElement);
|
65
apps/ssb/tf-tab-mentions.js
Normal file
65
apps/ssb/tf-tab-mentions.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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.*
|
||||||
|
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);
|
190
apps/ssb/tf-tab-news-feed.js
Normal file
190
apps/ssb/tf-tab-news-feed.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfTabNewsFeedElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
hash: {type: String},
|
||||||
|
following: {type: Array},
|
||||||
|
messages: {type: Array},
|
||||||
|
drafts: {type: Object},
|
||||||
|
expanded: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.whoami = null;
|
||||||
|
this.users = {};
|
||||||
|
this.hash = '#';
|
||||||
|
this.following = [];
|
||||||
|
this.drafts = {};
|
||||||
|
this.expanded = {};
|
||||||
|
this.start_time = new Date().valueOf() - 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch_messages() {
|
||||||
|
if (this.hash.startsWith('#@')) {
|
||||||
|
let r = await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
WITH mine AS (SELECT messages.*
|
||||||
|
FROM messages
|
||||||
|
WHERE messages.author = ?
|
||||||
|
ORDER BY sequence DESC
|
||||||
|
LIMIT 20)
|
||||||
|
SELECT messages.*
|
||||||
|
FROM mine
|
||||||
|
JOIN messages_refs ON mine.id = messages_refs.ref
|
||||||
|
JOIN messages ON messages_refs.message = messages.id
|
||||||
|
UNION
|
||||||
|
SELECT * FROM mine
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
this.hash.substring(1),
|
||||||
|
]);
|
||||||
|
return r;
|
||||||
|
} else if (this.hash.startsWith('#%')) {
|
||||||
|
return await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
SELECT messages.*
|
||||||
|
FROM messages
|
||||||
|
WHERE id = ?1
|
||||||
|
UNION
|
||||||
|
SELECT messages.*
|
||||||
|
FROM messages JOIN messages_refs
|
||||||
|
ON messages.id = messages_refs.message
|
||||||
|
WHERE messages_refs.ref = ?1
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
this.hash.substring(1),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
return await tfrpc.rpc.query(
|
||||||
|
`
|
||||||
|
WITH news AS (SELECT messages.*
|
||||||
|
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.*
|
||||||
|
FROM news
|
||||||
|
JOIN messages_refs ON news.id = messages_refs.ref
|
||||||
|
JOIN messages ON messages_refs.message = messages.id
|
||||||
|
UNION
|
||||||
|
SELECT messages.*
|
||||||
|
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,
|
||||||
|
/*
|
||||||
|
** 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.*
|
||||||
|
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.*
|
||||||
|
FROM news
|
||||||
|
JOIN messages_refs ON news.id = messages_refs.ref
|
||||||
|
JOIN messages ON messages_refs.message = messages.id
|
||||||
|
UNION
|
||||||
|
SELECT messages.*
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(messages) {
|
||||||
|
let result = [];
|
||||||
|
for (let message of messages) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = JSON.parse(message?.content);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (typeof(content) === 'string') {
|
||||||
|
let decrypted;
|
||||||
|
try {
|
||||||
|
decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content);
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
if (decrypted) {
|
||||||
|
try {
|
||||||
|
message.decrypted = JSON.parse(decrypted);
|
||||||
|
} catch {
|
||||||
|
message.decrypted = decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(message);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async add_messages(messages) {
|
||||||
|
this.messages = await this.decrypt([...messages, ...this.messages]);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.messages ||
|
||||||
|
this._messages_hash !== this.hash ||
|
||||||
|
this._messages_following !== this.following) {
|
||||||
|
console.log(`loading messages for ${this.whoami}`);
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let more;
|
||||||
|
if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) {
|
||||||
|
more = html`
|
||||||
|
<input type="button" value="Load More" @click=${this.load_more}></input>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following} .drafts=${this.drafts} .expanded=${this.expanded}></tf-news>
|
||||||
|
${more}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-tab-news-feed', TfTabNewsFeedElement);
|
119
apps/ssb/tf-tab-news.js
Normal file
119
apps/ssb/tf-tab-news.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfTabNewsElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
hash: {type: String},
|
||||||
|
unread: {type: Array},
|
||||||
|
following: {type: Array},
|
||||||
|
drafts: {type: Object},
|
||||||
|
expanded: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.whoami = null;
|
||||||
|
this.users = {};
|
||||||
|
this.hash = '#';
|
||||||
|
this.unread = [];
|
||||||
|
this.following = [];
|
||||||
|
this.cache = {};
|
||||||
|
this.drafts = {};
|
||||||
|
this.expanded = {};
|
||||||
|
tfrpc.rpc.localStorageGet('drafts').then(function(d) {
|
||||||
|
self.drafts = JSON.parse(d || '{}');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
document.body.addEventListener('keypress', this.on_keypress.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new_messages_text() {
|
||||||
|
if (!this.unread?.length) {
|
||||||
|
return 'No new messages.';
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
let id = event.detail.id || '';
|
||||||
|
let previous = this.drafts[id];
|
||||||
|
if (event.detail.draft !== undefined) {
|
||||||
|
this.drafts[id] = event.detail.draft;
|
||||||
|
} else {
|
||||||
|
delete this.drafts[id];
|
||||||
|
}
|
||||||
|
/* Only trigger a re-render if we're creating a new draft or discarding an old one. */
|
||||||
|
if ((previous !== undefined) != (event.detail.draft !== undefined)) {
|
||||||
|
this.drafts = Object.assign({}, this.drafts);
|
||||||
|
}
|
||||||
|
tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on_keypress(event) {
|
||||||
|
if (event.target === document.body &&
|
||||||
|
event.key == '.') {
|
||||||
|
this.show_more();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let profile = this.hash.startsWith('#@') ?
|
||||||
|
html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
|
||||||
|
return html`
|
||||||
|
<div><input type="button" value=${this.new_messages_text()} @click=${this.show_more}></input></div>
|
||||||
|
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
|
||||||
|
<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-tab-news', TfTabNewsElement);
|
114
apps/ssb/tf-tab-query.js
Normal file
114
apps/ssb/tf-tab-query.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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">
|
||||||
|
<textarea id="search" rows=8 style="flex: 1" @keydown=${this.search_keydown}>${this.query}</textarea>
|
||||||
|
<input type="button" value="Execute" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input>
|
||||||
|
</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);
|
87
apps/ssb/tf-tab-search.js
Normal file
87
apps/ssb/tf-tab-search.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfTabSearchElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
whoami: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
following: {type: Array},
|
||||||
|
query: {type: String},
|
||||||
|
expanded: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
let self = this;
|
||||||
|
this.whoami = null;
|
||||||
|
this.users = {};
|
||||||
|
this.following = [];
|
||||||
|
this.expanded = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
console.log('Searching...', this.whoami, query);
|
||||||
|
let search = this.renderRoot.getElementById('search');
|
||||||
|
if (search ) {
|
||||||
|
search.value = query;
|
||||||
|
search.focus();
|
||||||
|
search.select();
|
||||||
|
}
|
||||||
|
await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query));
|
||||||
|
let results = await tfrpc.rpc.query(`
|
||||||
|
SELECT messages.*
|
||||||
|
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.renderRoot.getElementById('news').messages = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
search_keydown(event) {
|
||||||
|
if (event.keyCode == 13) {
|
||||||
|
this.query = this.renderRoot.getElementById('search').value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
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">
|
||||||
|
<input type="text" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input>
|
||||||
|
<input type="button" value="Search" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input>
|
||||||
|
</div>
|
||||||
|
<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-search', TfTabSearchElement);
|
24
apps/ssb/tf-tag.js
Normal file
24
apps/ssb/tf-tag.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfTagElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
tag: {type: String},
|
||||||
|
count: {type: Number},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-tag', TfTagElement);
|
44
apps/ssb/tf-user.js
Normal file
44
apps/ssb/tf-user.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {LitElement, html} from './lit-all.min.js';
|
||||||
|
import * as tfrpc from '/static/tfrpc.js';
|
||||||
|
import {styles} from './tf-styles.js';
|
||||||
|
|
||||||
|
class TfUserElement extends LitElement {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
id: {type: String},
|
||||||
|
users: {type: Object},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = styles;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.id = null;
|
||||||
|
this.users = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
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>`;
|
||||||
|
|
||||||
|
if (this.users[this.id]) {
|
||||||
|
let image = this.users[this.id].image;
|
||||||
|
image = typeof(image) == 'string' ? image : image?.link;
|
||||||
|
return html`
|
||||||
|
<div style="display: inline-block; font-weight: bold">
|
||||||
|
<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
|
||||||
|
${name}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
return html`
|
||||||
|
<div style="display: inline-block; font-weight: bold">
|
||||||
|
${name}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('tf-user', TfUserElement);
|
93
apps/ssb/tf-utils.js
Normal file
93
apps/ssb/tf-utils.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import * as linkify from './commonmark-linkify.js';
|
||||||
|
import * as hashtagify from './commonmark-hashtag.js';
|
||||||
|
|
||||||
|
function image(node, entering) {
|
||||||
|
if (node.firstChild?.type === 'text' &&
|
||||||
|
node.firstChild.literal.startsWith('video:')) {
|
||||||
|
if (entering) {
|
||||||
|
this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||||
|
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
this.lit('</video>');
|
||||||
|
}
|
||||||
|
} else if (node.firstChild?.type === 'text' &&
|
||||||
|
node.firstChild.literal.startsWith('audio:')) {
|
||||||
|
if (entering) {
|
||||||
|
this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||||
|
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
this.lit('</audio>');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (entering) {
|
||||||
|
if (this.disableTags === 0) {
|
||||||
|
this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>');
|
||||||
|
if (this.options.safe && potentiallyUnsafe(node.destination)) {
|
||||||
|
this.lit('<img src="" alt="');
|
||||||
|
} else {
|
||||||
|
this.lit('<img src="' + this.esc(node.destination) + '" alt="');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.disableTags += 1;
|
||||||
|
} else {
|
||||||
|
this.disableTags -= 1;
|
||||||
|
if (this.disableTags === 0) {
|
||||||
|
if (node.title) {
|
||||||
|
this.lit('" title="' + this.esc(node.title));
|
||||||
|
}
|
||||||
|
this.lit('" />');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdown(md) {
|
||||||
|
var reader = new commonmark.Parser({safe: true});
|
||||||
|
var writer = new commonmark.HtmlRenderer();
|
||||||
|
writer.image = image;
|
||||||
|
var parsed = reader.parse(md || '');
|
||||||
|
parsed = linkify.transform(parsed);
|
||||||
|
parsed = hashtagify.transform(parsed);
|
||||||
|
var walker = parsed.walker();
|
||||||
|
var event, node;
|
||||||
|
while ((event = walker.next())) {
|
||||||
|
node = event.node;
|
||||||
|
if (event.entering) {
|
||||||
|
if (node.type == 'link') {
|
||||||
|
if (node.destination.startsWith('@') &&
|
||||||
|
node.destination.endsWith('.ed25519')) {
|
||||||
|
node.destination = '#' + node.destination;
|
||||||
|
} else if (node.destination.startsWith('%') &&
|
||||||
|
node.destination.endsWith('.sha256')) {
|
||||||
|
node.destination = '#' + node.destination;
|
||||||
|
} else if (node.destination.startsWith('&') &&
|
||||||
|
node.destination.endsWith('.sha256')) {
|
||||||
|
node.destination = '/' + node.destination + '/view';
|
||||||
|
}
|
||||||
|
} else if (node.type == 'image') {
|
||||||
|
if (node.destination.startsWith('&')) {
|
||||||
|
node.destination = '/' + node.destination + '/view';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writer.render(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function human_readable_size(bytes) {
|
||||||
|
let v = bytes;
|
||||||
|
let u = 'B';
|
||||||
|
for (let unit of ['kB', 'MB', 'GB']) {
|
||||||
|
if (v > 1024) {
|
||||||
|
v /= 1024;
|
||||||
|
u = unit;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${Math.round(v * 10) / 10} ${u}`;
|
||||||
|
}
|
32
apps/ssb/tribute.css
Normal file
32
apps/ssb/tribute.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.tribute-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
display: block;
|
||||||
|
z-index: 999999;
|
||||||
|
}
|
||||||
|
.tribute-container ul {
|
||||||
|
margin: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #efefef;
|
||||||
|
}
|
||||||
|
.tribute-container li {
|
||||||
|
padding: 5px 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tribute-container li.highlight {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
.tribute-container li span {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.tribute-container li.no-match {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.tribute-container .menu-highlighted {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
1797
apps/ssb/tribute.esm.js
Normal file
1797
apps/ssb/tribute.esm.js
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user