mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-03-18 06:17:32 +01:00
Compare commits
851 Commits
v2024.01.0
...
next-gen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d25229a0c | ||
|
|
c1ae648bcd | ||
|
|
56c5475702 | ||
|
|
b3c453b6c4 | ||
|
|
6e6f74f735 | ||
|
|
3eebc26106 | ||
|
|
c3ad0c2340 | ||
|
|
7b281967c8 | ||
|
|
f7b4f3f408 | ||
|
|
026da1c562 | ||
|
|
e444b0ca4e | ||
|
|
bcde91491b | ||
| 72f07f2a75 | |||
|
|
e7dce5e2b6 | ||
|
|
61cc882fbc | ||
|
|
96ac92957d | ||
|
|
75f2e4f039 | ||
|
|
93e28b24e5 | ||
|
|
79d28a0e50 | ||
|
|
59be1f4b2b | ||
|
|
ec5c2125fa | ||
|
|
daa5b9e7bc | ||
|
|
83bb7b4682 | ||
|
|
1186431a9c | ||
|
|
e115464cc8 | ||
|
|
83781e521e | ||
|
|
876d02d00e | ||
|
|
73bfe93269 | ||
|
|
8d94162233 | ||
|
|
4b0b9b01e4 | ||
|
|
27b97ecdae | ||
|
|
21b44b6e6e | ||
|
|
000f60569d | ||
|
|
0ccbe7933b | ||
|
|
5023671c15 | ||
|
|
6e56d94448 | ||
|
|
c7175f942e | ||
|
|
2cbd888b1b | ||
|
|
fb90a97795 | ||
|
|
8a24b3818f | ||
|
|
3320c1a2d7 | ||
|
|
1970aa6be5 | ||
|
|
2cb61f5895 | ||
|
|
abf4cb0df1 | ||
|
|
de1c9ee744 | ||
|
|
7754cccc2e | ||
|
|
769483eaf5 | ||
|
|
f433970574 | ||
|
|
aa6d8a28c9 | ||
|
|
99760751eb | ||
|
|
5f9cf2c25b | ||
|
|
5abe9e7d74 | ||
|
|
aca1e07f84 | ||
|
|
c357ce4ae5 | ||
|
|
afa12c89ec | ||
|
|
a72505e63b | ||
|
|
64d2af770c | ||
|
|
93d37ab3e8 | ||
|
|
b14bff2d0e | ||
|
|
30a7b1a12a | ||
|
|
4cd8636e4a | ||
|
|
52b8888c3b | ||
|
|
6f0514fd6a | ||
|
|
0d8395c6fc | ||
|
|
86adf2311a | ||
|
|
8402bc6d82 | ||
|
|
c275ed9cf8 | ||
|
|
13a9a6538d | ||
|
|
25ecd8b5fd | ||
|
|
5d4ac82c4d | ||
|
|
de10c927d9 | ||
|
|
ca3ee06738 | ||
|
|
6ee67cc886 | ||
|
|
f45af14229 | ||
|
|
a99611215e | ||
|
|
2b978bcafc | ||
|
|
fdcc77989e | ||
|
|
3e239f05c8 | ||
|
|
9bb1522eca | ||
|
|
6a5e4407ac | ||
|
|
d87d7ffcb5 | ||
|
|
4a9214ab71 | ||
|
|
f5b19b3457 | ||
|
|
87915d9f22 | ||
|
|
f7f12a1c7b | ||
|
|
ec8d6ecada | ||
|
|
e2156efa14 | ||
|
|
a919d31f7c | ||
|
|
1f7459800a | ||
|
|
3b64f5f35a | ||
|
|
cdb97579e9 | ||
|
|
51d019f035 | ||
| beb676d9ab | |||
| e5822ac8ee | |||
| 320c7a361c | |||
| 7c131b6936 | |||
| 198b1eefb7 | |||
| 363d2082c0 | |||
|
|
d65109d935 | ||
| 9688c20dd9 | |||
| 4d20d7744a | |||
| 29e52288df | |||
| 9ed2f6771e | |||
| 302c22ab9f | |||
| da493a88ea | |||
|
|
0512d53043 | ||
| 04dca7681b | |||
| 04843616b3 | |||
| 512e2e1684 | |||
| 7a9337c07a | |||
| 15a9df2ca9 | |||
| 4df58f0880 | |||
|
|
56d05b60a1 | ||
|
|
932e44a9c9 | ||
| 31bab4cf8a | |||
| cb0bb4cf27 | |||
| ccaa0d98ff | |||
| dba26f735c | |||
|
|
9d9d86160d | ||
|
|
d3a12b9016 | ||
|
|
eacce7c197 | ||
|
|
1801d0ae73 | ||
|
|
41e2fc0d10 | ||
|
|
aeac36b65d | ||
|
|
0e4a1cbe65 | ||
|
|
7bf8e1670f | ||
|
|
2efed8d5c6 | ||
| 9b60abb3a0 | |||
|
|
f4c2dd5239 | ||
|
|
067957edbf | ||
|
|
b2b98e38b5 | ||
|
|
9f9ea2fec4 | ||
|
|
eac75d93fd | ||
|
|
39608774e7 | ||
|
|
c182b8517b | ||
|
|
96b5acf4e9 | ||
|
|
01109a6436 | ||
|
|
cb98f74ba8 | ||
|
|
243dfdacb4 | ||
|
|
06732cf9cf | ||
|
|
f39971a8d0 | ||
|
|
5d7994a696 | ||
|
|
5d400f9211 | ||
|
|
2d3590a2a0 | ||
|
|
318d0bcdf9 | ||
|
|
9df29c8b51 | ||
|
|
8abee93749 | ||
|
|
81b1db072c | ||
|
|
d1e20fa776 | ||
|
|
1f679e731d | ||
|
|
7659ee9a9e | ||
|
|
9939713075 | ||
|
|
170d4ebbd5 | ||
|
|
196a71fbd9 | ||
|
|
e48ff3d642 | ||
|
|
39e25532d7 | ||
|
|
25f563464b | ||
|
|
08bb5b41c9 | ||
|
|
486001a46c | ||
|
|
4e255b5f6e | ||
|
|
877040b086 | ||
|
|
c294b967ae | ||
|
|
16cc1fec8b | ||
|
|
fed6f6da60 | ||
|
|
89160497e4 | ||
|
|
beb621de97 | ||
|
|
b46fadd375 | ||
|
|
f0fbfab3b3 | ||
|
|
f83a3e16ce | ||
|
|
b309d23c8b | ||
|
|
d879a9de5c | ||
|
|
b419f8fb23 | ||
|
|
7a4a454376 | ||
|
|
e94ea49142 | ||
|
|
56415c7506 | ||
|
|
c8879e7998 | ||
|
|
8fb30edf31 | ||
|
|
3db6ee3a17 | ||
| e4520a14e8 | |||
| a13e1f1f10 | |||
| 377578dbe2 | |||
| c436fbc944 | |||
|
|
7399ac46d4 | ||
| 3af372973d | |||
| de837e15b9 | |||
| 49eaa57027 | |||
|
|
977de1332a | ||
|
|
cbafb7da69 | ||
|
|
966d43ca29 | ||
|
|
193f9bfb66 | ||
|
|
2f77675562 | ||
|
|
b13dd986fc | ||
|
|
4269124758 | ||
|
|
6be0e82610 | ||
|
|
a6e8f2969a | ||
|
|
eb3a038a60 | ||
|
|
bb60eaa2fc | ||
|
|
46654ef054 | ||
|
|
981d98e5e2 | ||
|
|
7eda523520 | ||
|
|
ea54406127 | ||
|
|
1db78da82f | ||
|
|
9c0b7f0c78 | ||
|
|
e62b4d9d77 | ||
|
|
a99f2e9e64 | ||
|
|
0ad32886e1 | ||
|
|
b1bf2ea95f | ||
|
|
b62b83fa00 | ||
|
|
bfd0d938e1 | ||
|
|
4d2d044aa9 | ||
|
|
e8e1c4dedf | ||
|
|
dd23074177 | ||
|
|
127cd5ddf3 | ||
|
|
cdeab8ffc6 | ||
|
|
d2fc571100 | ||
|
|
4a27571e04 | ||
|
|
f3296d646e | ||
|
|
8c0a863770 | ||
|
|
2b8370a377 | ||
|
|
a4d5066156 | ||
| ebabace52d | |||
|
|
ed0cc67ba5 | ||
|
|
dce625547d | ||
|
|
06c370dfd6 | ||
|
|
db83abdbd3 | ||
|
|
11a60c52f3 | ||
|
|
59f08b991e | ||
|
|
3c4e9a8fa7 | ||
|
|
c632743db3 | ||
|
|
6511a3a889 | ||
|
|
66383b8bda | ||
|
|
d24f89947e | ||
|
|
8a2d6631da | ||
|
|
95d0b575c8 | ||
|
|
1e39ce69ef | ||
|
|
905b0fc255 | ||
|
|
3bf39d2349 | ||
|
|
04b43993eb | ||
|
|
321b358a62 | ||
|
|
83d752c24d | ||
|
|
d8c0094003 | ||
|
|
9124476ce0 | ||
|
|
148e831624 | ||
|
|
5112c309ea | ||
|
|
a0642853e8 | ||
|
|
0a4f2e1936 | ||
|
|
3ae3a304a1 | ||
|
|
23e3327029 | ||
|
|
5fd45b5343 | ||
|
|
706c863cd8 | ||
|
|
a7942c4ffe | ||
|
|
7a4d0b9bf1 | ||
|
|
db8983e469 | ||
|
|
1393813c4e | ||
|
|
4ee3363140 | ||
|
|
5dad01130f | ||
|
|
a5ba726230 | ||
|
|
4070c1bb43 | ||
|
|
73d9474679 | ||
|
|
2bf1c68492 | ||
|
|
f60beb3eab | ||
|
|
f00029a154 | ||
|
|
7ed053c929 | ||
|
|
0de60d085a | ||
|
|
e8443cd526 | ||
|
|
12981ee707 | ||
|
|
3d1b9b9c5e | ||
|
|
dd3d1308b7 | ||
|
|
0fdca4d842 | ||
|
|
985e5d42ea | ||
|
|
80f44a99ca | ||
|
|
04692fb708 | ||
|
|
3cb9a8983d | ||
|
|
29dfa709ef | ||
|
|
7dbfeeced9 | ||
|
|
655828946c | ||
|
|
ca91fe1cbe | ||
|
|
c2ce5bde82 | ||
|
|
4bb65fe624 | ||
|
|
28bcb0f52f | ||
|
|
49a8cd4e65 | ||
|
|
e428b41d85 | ||
|
|
a929db05d2 | ||
|
|
a624a97d14 | ||
|
|
60ac77adf5 | ||
|
|
5cd5179c3e | ||
|
|
6c9ef7ea54 | ||
|
|
6872f4fd1c | ||
|
|
a45e7dd7eb | ||
|
|
ea8d8a9296 | ||
|
|
66bc366da3 | ||
|
|
267875c70a | ||
|
|
e68ba5778a | ||
|
|
82bcf1dbbf | ||
|
|
4779c1d004 | ||
|
|
e295e60fc0 | ||
|
|
e383f15ee8 | ||
|
|
aacbdabee3 | ||
|
|
f290bd80f1 | ||
|
|
8e999e67d4 | ||
|
|
4a0dbd2140 | ||
|
|
4de969d9cd | ||
|
|
b1ad31b2ee | ||
|
|
92f7bfeebd | ||
|
|
1dffa2a6f9 | ||
|
|
b4288ce51e | ||
|
|
345ec6ffb0 | ||
|
|
436e58dae0 | ||
|
|
48d55de460 | ||
|
|
b15151ddf3 | ||
|
|
a2312e8874 | ||
|
|
663431ef5d | ||
|
|
f3cba380ba | ||
|
|
4c8c7ed384 | ||
|
|
0395b372bd | ||
|
|
a2c5bbe65c | ||
|
|
b0ca33c9d8 | ||
|
|
350b25cc67 | ||
|
|
51cf283421 | ||
|
|
fb227886d2 | ||
|
|
0d49b11411 | ||
|
|
43af826eb0 | ||
|
|
b56379e485 | ||
|
|
f8c770947f | ||
|
|
a55dc1b4aa | ||
|
|
cd146e9cf6 | ||
|
|
ecb56ae4f0 | ||
|
|
e58543d6c4 | ||
|
|
35917ee05a | ||
|
|
411465db67 | ||
|
|
8eed4a12c9 | ||
|
|
894f3fd113 | ||
|
|
014c510061 | ||
|
|
f55ff3a3a9 | ||
|
|
67145df1b6 | ||
|
|
0b8f124088 | ||
|
|
12e80dc7e2 | ||
|
|
f58b1b03d2 | ||
|
|
8fc627b77a | ||
|
|
f99282025b | ||
|
|
04916588c3 | ||
|
|
c6501664ee | ||
|
|
f5ba3fdf14 | ||
|
|
e6be7c2563 | ||
|
|
35cba4c6c6 | ||
|
|
4e11d2562a | ||
|
|
00a97a66a4 | ||
|
|
0030b1c65d | ||
|
|
689afb0682 | ||
|
|
08316733d3 | ||
|
|
e5bdddd45c | ||
|
|
b3cd888424 | ||
|
|
f9577de904 | ||
|
|
b011879b0d | ||
|
|
6a64147d96 | ||
|
|
a45138d68b | ||
|
|
47bb008710 | ||
| a64cc0a28c | |||
| bbef9d988b | |||
|
|
a5afd1b1a8 | ||
| 487282d512 | |||
| 54d0f70f38 | |||
|
|
b78eec35f8 | ||
|
|
84b8932df9 | ||
| 128575df05 | |||
| 64af7dc02b | |||
| 6a8ef9b523 | |||
| 2a4a17fdeb | |||
|
|
bea28d8d19 | ||
|
|
89a89778aa | ||
| 2b4a66aa05 | |||
| 70a1c9fbcc | |||
| 76dae8bcf8 | |||
| b432873500 | |||
| 45bee2bf57 | |||
| 921fe631e0 | |||
| 582d14708f | |||
| ea79165362 | |||
| 460d71075b | |||
| 3d7d1142d3 | |||
| 6aa84daf5e | |||
| b37edc3712 | |||
| 9ff772f098 | |||
|
|
6974c6beba | ||
| ff196693df | |||
| 00e5766f90 | |||
| 3f72185817 | |||
| 03a90922d8 | |||
| 38fe9c149b | |||
| cdee0a620c | |||
| fccabf6eb5 | |||
| 3dae674c4e | |||
| c76134b064 | |||
| 284c2bd87f | |||
| 543295fc21 | |||
|
|
894cc24eca | ||
|
|
49849605c4 | ||
|
|
fc18e71f40 | ||
|
|
595458aa46 | ||
|
|
87e1f7eb7a | ||
|
|
eefe2ad454 | ||
|
|
928f207668 | ||
|
|
7c75c2ddec | ||
| 469a398db7 | |||
|
|
5f1671ac54 | ||
|
|
67728021d2 | ||
|
|
2eaf5bf6da | ||
|
|
12b7c9de89 | ||
|
|
fbd0adf292 | ||
|
|
38bd99c2f1 | ||
|
|
879a251209 | ||
|
|
976703aa36 | ||
|
|
95042dca93 | ||
|
|
0a46403f6c | ||
|
|
f958e47265 | ||
|
|
831ea5225e | ||
|
|
5a7cf68858 | ||
| 6219b082de | |||
| 321079a639 | |||
| 425860de98 | |||
|
|
fb85aac231 | ||
|
|
86adc942ca | ||
|
|
1ec37d5a1c | ||
|
|
bae927411f | ||
|
|
528146310a | ||
|
|
0d2e256254 | ||
|
|
b425462cc6 | ||
|
|
2ad93154de | ||
|
|
e905040c68 | ||
|
|
db53593fef | ||
|
|
35bb5282bc | ||
|
|
608f2a248d | ||
|
|
2525e5ca0d | ||
|
|
5c014bcb07 | ||
|
|
f81e31529d | ||
|
|
f874fb3f81 | ||
|
|
ca19afe3a0 | ||
|
|
930c0d6529 | ||
| a84305b6d3 | |||
| 6b97c5123f | |||
| 0322939cba | |||
| 8ed80da82c | |||
| 90acd9ebea | |||
|
|
58152ef3dc | ||
|
|
b433f0a113 | ||
|
|
2192675071 | ||
|
|
000516678c | ||
|
|
1e382b55f1 | ||
|
|
8a7b9e9368 | ||
|
|
26bede0b05 | ||
|
|
8a9d034d60 | ||
|
|
4a46896372 | ||
|
|
4352e1510d | ||
|
|
c4728fbb34 | ||
|
|
f82dc9b3b8 | ||
|
|
403e4c2719 | ||
|
|
2b7dbe0a2c | ||
|
|
8b1744fe01 | ||
|
|
c21bb125b9 | ||
|
|
4ef3406a07 | ||
|
|
28954a4968 | ||
|
|
77bd3c8290 | ||
|
|
fba1854795 | ||
|
|
d21bf7c97d | ||
|
|
f2db08ad7f | ||
|
|
14248ed0ff | ||
|
|
7fdc48fbaf | ||
|
|
aec18b1bcf | ||
|
|
b07bc39e57 | ||
|
|
78eee2c2d8 | ||
|
|
8c170d12e2 | ||
|
|
deb84ba2c4 | ||
|
|
ea3286b452 | ||
|
|
84d2e4b4bd | ||
|
|
e0ba68f1ed | ||
|
|
1ede045625 | ||
|
|
b844fec832 | ||
|
|
2ab15b3f6e | ||
| 6910809a97 | |||
|
|
0f19ac0a3f | ||
|
|
05c397c252 | ||
| 329010e8e0 | |||
|
|
ec1b8a0df2 | ||
|
|
9c3868bfc9 | ||
| 40689caef6 | |||
| 021dd0ae52 | |||
| 002d0a5709 | |||
| 10b17b2d6f | |||
| 0f5a10c3fa | |||
| 590d91c2d4 | |||
| 5f7f34f29d | |||
|
|
90be9a09bf | ||
|
|
8445140df9 | ||
|
|
4fc8bc1ddc | ||
|
|
3ebc137208 | ||
|
|
0ff0cc9cd9 | ||
|
|
6d63195f72 | ||
|
|
3129b0961e | ||
|
|
156c62cc43 | ||
|
|
81af079fb5 | ||
|
|
987b4a3f32 | ||
|
|
a426e23138 | ||
|
|
7a66d9aaae | ||
|
|
aa54cd6f24 | ||
|
|
8e7366c5ed | ||
|
|
1088190830 | ||
|
|
1e009bdd11 | ||
|
|
2a031ab1ef | ||
|
|
ce9099c230 | ||
|
|
396b8a5465 | ||
|
|
799f432ea4 | ||
|
|
c0cf43b12d | ||
|
|
98a1398c17 | ||
|
|
979b42dd2c | ||
|
|
bcfee1489f | ||
|
|
4f33a3e765 | ||
|
|
d424783689 | ||
|
|
dd6d771d6e | ||
|
|
898fc6d81a | ||
|
|
eb2ff2f543 | ||
|
|
b33c975632 | ||
|
|
e3acbf2b76 | ||
|
|
68625880f8 | ||
|
|
9dfd2cd2b6 | ||
|
|
8d22b475ce | ||
|
|
ed68871645 | ||
|
|
6860f5a6f3 | ||
|
|
d57cae035d | ||
|
|
7d3b2a8181 | ||
|
|
0907e05d81 | ||
|
|
955a6e27d3 | ||
|
|
41d56259da | ||
|
|
230146d20e | ||
|
|
793315c151 | ||
|
|
84d120d17b | ||
|
|
13e598c212 | ||
|
|
8d9af25797 | ||
|
|
9ba426c101 | ||
|
|
be9ad7d3ba | ||
|
|
1ad9f11c1b | ||
|
|
ab07faec2d | ||
|
|
3bd7357b10 | ||
|
|
8e9d5333ef | ||
|
|
bd5433f9ba | ||
|
|
0a72669396 | ||
|
|
c951549942 | ||
|
|
3c5a856383 | ||
|
|
48753291c3 | ||
|
|
1f11fd4503 | ||
|
|
e968d4df7c | ||
|
|
c30b1a61ad | ||
|
|
5376a63375 | ||
|
|
5b4d2accbf | ||
|
|
95714425b3 | ||
|
|
62ae44b1c6 | ||
|
|
b14f5ffd00 | ||
|
|
4ad6cf6f34 | ||
|
|
c41c19a03a | ||
|
|
f34c4a66ff | ||
|
|
b57fb401b9 | ||
|
|
13aeb23972 | ||
|
|
9a4015b448 | ||
|
|
634c41cca5 | ||
|
|
e12e237f05 | ||
|
|
23762fe0c0 | ||
|
|
a8831abe6c | ||
|
|
dae7e39cf0 | ||
|
|
06f55753bc | ||
|
|
2c856d928b | ||
|
|
f616574c91 | ||
|
|
d55e1937ee | ||
|
|
d58b1316e6 | ||
|
|
298d935285 | ||
|
|
6287def612 | ||
|
|
4577952a71 | ||
|
|
e03edf22ad | ||
|
|
2a0e265411 | ||
|
|
31f5835de8 | ||
|
|
b69e4f376f | ||
|
|
9883a3bd9d | ||
|
|
57bb08cd04 | ||
|
|
2059e476fb | ||
|
|
6611bdafc3 | ||
|
|
563ca968e6 | ||
|
|
fe457fabe6 | ||
|
|
d31f226b11 | ||
|
|
418590f82d | ||
|
|
61a8429870 | ||
|
|
1849532c58 | ||
|
|
72285e4620 | ||
|
|
77e4dcef04 | ||
|
|
3bba679d48 | ||
|
|
71677c27e3 | ||
|
|
6d78004dc7 | ||
|
|
32a320fdf8 | ||
|
|
2325872a38 | ||
|
|
93e52492d3 | ||
|
|
05e226f812 | ||
|
|
92d15e0905 | ||
|
|
83a38951a9 | ||
|
|
63cb6c0df0 | ||
|
|
70202a66a3 | ||
|
|
cc30c676ef | ||
|
|
5132b7dbeb | ||
|
|
bab9215059 | ||
|
|
d63ca0281b | ||
|
|
c0ce551727 | ||
|
|
2937ee6794 | ||
|
|
ae39b9496d | ||
|
|
3dc226a15a | ||
|
|
f1fcba5aa7 | ||
|
|
6fde0eea08 | ||
|
|
19509cc1fa | ||
|
|
5ab652a7f7 | ||
|
|
767608584d | ||
|
|
1cb6f4889b | ||
|
|
465ada9296 | ||
|
|
6b78f91bc7 | ||
|
|
f13aad9d7f | ||
|
|
e72b4cd997 | ||
|
|
9d13060d65 | ||
|
|
edb0f0cb2a | ||
|
|
06f68eacf8 | ||
|
|
3ac12bb179 | ||
|
|
ee294908ff | ||
|
|
1b5bb3b7ae | ||
|
|
9142cad3c2 | ||
|
|
81c09e6759 | ||
|
|
db7d032916 | ||
|
|
69729f5ac0 | ||
|
|
d5235e7881 | ||
|
|
9e4691bc05 | ||
|
|
738182cf3a | ||
|
|
ab2def8145 | ||
|
|
9226ecdd90 | ||
|
|
0ec60eb54f | ||
|
|
ae08334335 | ||
|
|
a0aa6262f9 | ||
|
|
10b8b4214e | ||
|
|
cea32f95d0 | ||
|
|
b27be24cb7 | ||
|
|
5c321e7601 | ||
|
|
1ebbd90701 | ||
|
|
70883d844b | ||
|
|
d01085427a | ||
|
|
ee74605b19 | ||
|
|
aaf7fca14a | ||
|
|
9ed6af000d | ||
|
|
d24f114636 | ||
|
|
fd3ac675f4 | ||
|
|
4d161c3c7c | ||
|
|
89dd25370a | ||
|
|
cdeb0ece1c | ||
|
|
13c6094a89 | ||
|
|
2fd756aaff | ||
|
|
9eb87d50f6 | ||
|
|
1ec32bf119 | ||
|
|
9df58cc7b5 | ||
|
|
281a6853d8 | ||
|
|
5223d64f59 | ||
|
|
915dcad2e9 | ||
|
|
a2ebbcba60 | ||
|
|
089c984fbb | ||
|
|
9d66b23c8e | ||
|
|
33ccb0a8a9 | ||
|
|
c06c6beff4 | ||
| 438115d8e8 | |||
| 650747f423 | |||
|
|
80dd29fb13 | ||
| 28283c72a3 | |||
|
|
30ce19ad6c | ||
| a17616b3dc | |||
|
|
d5ad0fede5 | ||
| 52ee056759 | |||
| fe43ce562c | |||
| 503bf72b11 | |||
| 14a956d207 | |||
|
|
cd9756ae61 | ||
| 68651ff736 | |||
| 49c32ad4b8 | |||
| bd69745cef | |||
|
|
404e545a6d | ||
| 23468c7c63 | |||
| fb08352a91 | |||
| 00b33550f4 | |||
|
|
1b61545375 | ||
|
|
31beabe86e | ||
|
|
bb531a41d0 | ||
| 6db1e0b79c | |||
| 94ad7ce180 | |||
|
|
7112d0e159 | ||
| 5620635a36 | |||
| 24767437f5 | |||
| 0858b17f17 | |||
| e91391a3fa | |||
| 3320aa5509 | |||
| 4bb21e15f8 | |||
| 41d585226a | |||
| 9c2511561a | |||
| cd7279e83b | |||
| 533f15de55 | |||
| 10d2c47f9a | |||
| b39ccb9f2c | |||
| 2a1d86f917 | |||
| aee2a3b8b2 | |||
| fd1a64c7b8 | |||
| a5cd118b99 | |||
| ee4f254825 | |||
|
|
a1eb546f23 | ||
| 74360ac50f | |||
| c05dc4b211 | |||
| 5ad29130b9 | |||
| a322507f8b | |||
|
|
ea60f308c6 | ||
|
|
c784ca0cd5 | ||
|
|
04aa4b72f8 | ||
|
|
d4a6b95ea4 | ||
|
|
ce9c2b4456 | ||
|
|
7752fa6f58 | ||
|
|
cfbdab9b9b | ||
|
|
8c43525d6a | ||
|
|
6321ae68b5 | ||
|
|
1552c412bb | ||
|
|
1b46c21aa0 | ||
|
|
47f02ce0df | ||
|
|
72f10f3654 | ||
|
|
d2dbdb9a71 | ||
|
|
ad2a800dad | ||
|
|
174a5fad61 | ||
|
|
fa17af13af | ||
|
|
d2322afeba | ||
|
|
1c7ffb1f97 | ||
|
|
f1f83e875b | ||
|
|
ed3b25ec85 | ||
|
|
3ccd9018ed | ||
|
|
5da35a3039 | ||
|
|
11d9414670 | ||
|
|
6169bcfbc7 | ||
|
|
82791fa648 | ||
|
|
ada246863c | ||
|
|
1ac04422f9 | ||
|
|
184b000f78 | ||
|
|
1aa2ded0bb | ||
|
|
eba2d34223 | ||
|
|
75e6027381 | ||
|
|
698b1d8ae8 | ||
|
|
86dc45bc2b | ||
|
|
74f403b750 | ||
|
|
9422fd6fad | ||
|
|
0546679452 | ||
|
|
4adf8e4a47 | ||
|
|
69c6fb1ee6 | ||
|
|
e59d3da0fb | ||
|
|
a3163f4a08 | ||
|
|
b8bcff3157 | ||
|
|
72df3fa115 | ||
|
|
afb9d0414e | ||
|
|
327bc99924 | ||
|
|
b8b93a900a | ||
|
|
185d4c295b | ||
|
|
4c3ef2dda5 | ||
|
|
34ca6ca7d1 | ||
|
|
37728ce9ba | ||
|
|
479b4f8a76 | ||
|
|
0a853d9dfd | ||
|
|
9f1d3b1fb2 | ||
|
|
ba0fe23080 | ||
|
|
f06208106d | ||
|
|
5e074d40cf | ||
|
|
1eddebb775 | ||
|
|
9906047f16 | ||
|
|
6bccfe08b7 | ||
|
|
ff25f7cbd2 | ||
|
|
98aeafa0cc | ||
|
|
32fbf0417c | ||
|
|
3ed52ae1a8 | ||
|
|
9b8f989c35 | ||
|
|
0b6d93b376 | ||
|
|
b2282e47eb | ||
|
|
0e5fd4d2a1 | ||
|
|
d10fa02d75 | ||
|
|
ce9f3c0be4 | ||
|
|
c404beda68 | ||
|
|
e503766eee | ||
|
|
29188ead9c | ||
|
|
39525f8cb3 | ||
|
|
d7ca5c3820 | ||
|
|
cb1d06f05d | ||
|
|
177cf1a896 | ||
|
|
d93620a74f | ||
|
|
a571cef885 | ||
|
|
37f2870950 | ||
|
|
7a31f8e474 | ||
|
|
f5a9578331 | ||
|
|
c9e41a534e | ||
|
|
90df276479 | ||
|
|
b6a3324dc9 | ||
|
|
ed83d6b839 | ||
| d6d3f0810c | |||
| 9f80891e1d | |||
|
|
04853165b3 | ||
|
|
8777fde660 | ||
|
|
8a6d505205 | ||
|
|
e53848fd33 | ||
|
|
a533dccb38 | ||
|
|
7fb5ff3927 | ||
|
|
a8301c0df3 | ||
|
|
227b1934a8 | ||
|
|
4161392e12 | ||
|
|
9294310d65 | ||
|
|
1a1248e445 | ||
|
|
ad0a100838 | ||
|
|
30b87d447c | ||
|
|
085f63bcb5 | ||
|
|
5ac71413ce | ||
|
|
9765a1d9f4 | ||
|
|
bcf05d344a | ||
|
|
cfdc3abf08 | ||
|
|
7c93366b37 | ||
|
|
614f2b3aaa | ||
|
|
65d7ff8f8b | ||
|
|
70a2635dc2 | ||
|
|
eef6637f85 | ||
|
|
cd5fb8747b | ||
|
|
cedbb77eb5 | ||
|
|
4f6f98f56f | ||
|
|
18a89df400 | ||
|
|
935c7d7fa2 | ||
|
|
dab7b42bee | ||
|
|
58749b83af | ||
|
|
7249fbfc01 | ||
|
|
e926d4f817 | ||
|
|
870d6ed147 | ||
|
|
6e10f8e31c | ||
| 434328056e | |||
| 36420d6a5b | |||
|
|
ccd3edd2da | ||
|
|
c3744fb680 | ||
|
|
4d89e8c437 | ||
|
|
d3cc46b723 | ||
|
|
dc7d9b6923 | ||
|
|
22c5147c92 | ||
|
|
dda3d21008 | ||
|
|
871562fa4c | ||
| 88ddace811 | |||
| 8d1b0c73df | |||
| 3e3396687d | |||
| c9c78105a5 | |||
|
|
49898c4407 | ||
|
|
46c897221f | ||
|
|
7a83e399fc |
23
.claude/settings.local.json
Normal file
23
.claude/settings.local.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(findstr:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(python:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:bsky.app)",
|
||||||
|
"Bash(cmd /c \"dir /s /b %APPDATA%\\\\twblue\\\\*.log 2>nul\")",
|
||||||
|
"Bash(cmd /c \"dir /s /b %TEMP%\\\\twblue*.log 2>nul\")",
|
||||||
|
"Bash(cmd /c \"type C:\\\\Users\\\\Usuario\\\\Desktop\\\\repos\\\\twblue\\\\src\\\\logs\\\\debug.log | findstr /n . | findstr /r \"^[0-9]*:\" | tail -50\")",
|
||||||
|
"WebFetch(domain:atproto.blue)",
|
||||||
|
"Bash(fc:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(git push:*)",
|
||||||
|
"Bash(pip show:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "lief"
|
||||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
|||||||
- name: Get python interpreter
|
- name: Get python interpreter
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.13'
|
||||||
|
|
||||||
- name: Install python packages
|
- name: Install python packages
|
||||||
run: python -m pip install -r requirements.txt
|
run: python -m pip install -r requirements.txt
|
||||||
@@ -29,6 +29,12 @@ jobs:
|
|||||||
.\scripts\build.ps1
|
.\scripts\build.ps1
|
||||||
mv src/dist scripts\TWBlue64
|
mv src/dist scripts\TWBlue64
|
||||||
|
|
||||||
|
- name: Install NSIS
|
||||||
|
run: choco install nsis
|
||||||
|
|
||||||
|
- name: Add NSIS to PATH
|
||||||
|
run: echo "C:\Program Files (x86)\NSIS" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||||
|
|
||||||
- name: make installer
|
- name: make installer
|
||||||
run: |
|
run: |
|
||||||
cd scripts
|
cd scripts
|
||||||
@@ -46,4 +52,4 @@ jobs:
|
|||||||
mkdir .release-assets
|
mkdir .release-assets
|
||||||
mv scripts\TWBlue_setup.exe .release-assets\TWBlue_setup_${{github.ref_name}}.exe
|
mv scripts\TWBlue_setup.exe .release-assets\TWBlue_setup_${{github.ref_name}}.exe
|
||||||
mv scripts\TWBlue64\TWBlue_portable.zip .release-assets\TWBlue_portable_${{github.ref_name}}.zip
|
mv scripts\TWBlue64\TWBlue_portable.zip .release-assets\TWBlue_portable_${{github.ref_name}}.zip
|
||||||
gh release create release --draft -F "release-notes.md" -t "${{github.ref_name}}" .release-assets\TWBlue_setup_${{github.ref_name}}.exe .release-assets\TWBlue_portable_${{github.ref_name}}.zip
|
gh release create "${{github.ref_name}}" -F "release-notes.md" -t "${{github.ref_name}}" .release-assets\TWBlue_setup_${{github.ref_name}}.exe .release-assets\TWBlue_portable_${{github.ref_name}}.zip
|
||||||
342
CLAUDE.md
Normal file
342
CLAUDE.md
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
TWBlue is an accessible desktop Mastodon client for Windows, built with Python 3.10 and wxPython. It provides two specialized interfaces optimized for screen reader users to interact with Mastodon instances. The application emphasizes accessibility-first design with keyboard navigation, audio feedback, and screen reader integration.
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Running from Source
|
||||||
|
```bash
|
||||||
|
cd src
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
For development from source, VLC dependencies are loaded from `../windows-dependencies/{arch}/` where arch is x86 or x64.
|
||||||
|
|
||||||
|
### Installing Dependencies
|
||||||
|
```bash
|
||||||
|
# Install all Python dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Initialize git submodules for Windows dependencies
|
||||||
|
git submodule init
|
||||||
|
git submodule update
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
```bash
|
||||||
|
# Build binary distribution (from src/ directory)
|
||||||
|
python setup.py build
|
||||||
|
|
||||||
|
# Output will be in src/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Run tests using pytest
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Tests are located in src/test/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generating Documentation
|
||||||
|
```bash
|
||||||
|
cd doc
|
||||||
|
python documentation_importer.py
|
||||||
|
python generator.py
|
||||||
|
# Copy generated language folders to src/documentation/
|
||||||
|
# Copy license.txt to src/documentation/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation Management
|
||||||
|
```bash
|
||||||
|
# Extract translation strings (from doc/ directory)
|
||||||
|
pybabel extract -o twblue.pot --msgid-bugs-address "manuel@manuelcortez.net" --copyright-holder "MCV software" --input-dirs ../src
|
||||||
|
|
||||||
|
# Note: Translations managed via Weblate at https://weblate.mcvsoftware.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
TWBlue follows an MVC architecture with distinct separation between data access (Sessions), business logic (Controllers), and presentation (wxUI).
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
#### 1. Session Layer (`src/sessions/`)
|
||||||
|
Sessions represent authenticated connections to Mastodon instances. They manage API interactions, OAuth2 authentication, and persistent data storage.
|
||||||
|
|
||||||
|
- **Base Session** (`sessions/base.py`): Abstract base class with configuration management, SQLiteDict persistence, and decorators for login/configuration checks
|
||||||
|
- **Mastodon Session** (`sessions/mastodon/session.py`): Implements Mastodon.py API wrapper, OAuth2 flow, and account credential management
|
||||||
|
- **Streaming** (`sessions/mastodon/streaming.py`): Real-time event listener that publishes to pub/sub system
|
||||||
|
|
||||||
|
Key patterns:
|
||||||
|
- Sessions use `@_require_login` and `@_require_configuration` decorators
|
||||||
|
- Configuration files stored as INI format via configobj in `config/{session_id}/session.conf`
|
||||||
|
- Persistent data (caches, user lists) stored in `config/{session_id}/cache.db` using SQLiteDict
|
||||||
|
- Each session has its own sound system instance
|
||||||
|
|
||||||
|
#### 2. Buffer System (`src/controller/buffers/`)
|
||||||
|
Buffers are the primary data structures for displaying social media content (timelines, mentions, notifications, conversations, etc.).
|
||||||
|
|
||||||
|
**Base Buffer** (`controller/buffers/base/base.py`):
|
||||||
|
- Links buffer UI (wxPanel) with session (API access) and compose functions (data rendering)
|
||||||
|
- Handles keyboard events (F5/F6 for volume, Delete for item removal, Return for URLs)
|
||||||
|
- Manages periodic updates via `start_stream()` function
|
||||||
|
- Each buffer has a `compose_function` that formats API data for display
|
||||||
|
|
||||||
|
**Mastodon Buffers** (`controller/buffers/mastodon/`):
|
||||||
|
- `base.py`: Mastodon-specific base buffer with timeline pagination
|
||||||
|
- `users.py`: Home timeline, mentions buffer
|
||||||
|
- `community.py`: Local/federated timelines
|
||||||
|
- `notifications.py`: System notifications
|
||||||
|
- `conversations.py`: Direct message threads
|
||||||
|
- `search.py`: Search results
|
||||||
|
|
||||||
|
Buffer lifecycle:
|
||||||
|
1. Created by mainController when session initializes
|
||||||
|
2. Added to view (wx.Treebook)
|
||||||
|
3. Periodically updated via `start_stream()` or real-time via pub/sub events
|
||||||
|
4. Destroyed when session ends or buffer removed
|
||||||
|
|
||||||
|
#### 3. Controller Layer (`src/controller/`)
|
||||||
|
Controllers orchestrate application logic and coordinate between sessions, buffers, and UI.
|
||||||
|
|
||||||
|
**Main Controller** (`controller/mainController.py`):
|
||||||
|
- Manages all active buffers and sessions
|
||||||
|
- Binds keyboard shortcuts to actions
|
||||||
|
- Handles pub/sub event subscriptions
|
||||||
|
- Periodically calls `start_stream()` on visible buffers
|
||||||
|
- Provides buffer search methods: `search_buffer()`, `get_current_buffer()`, `get_best_buffer()`
|
||||||
|
|
||||||
|
**Specialized Controllers**:
|
||||||
|
- `settings.py`: Settings dialog management
|
||||||
|
- `userAlias.py` / `userList.py`: User management features
|
||||||
|
- `mastodon/handler.py`: Mastodon-specific operations (filters, etc.)
|
||||||
|
|
||||||
|
#### 4. GUI Layer (`src/wxUI/`)
|
||||||
|
wxPython-based interface with menu-driven navigation and list controls.
|
||||||
|
|
||||||
|
- **Main Frame** (`wxUI/view.py`): Primary window with wx.Treebook for buffers, menu system, system tray integration
|
||||||
|
- **Buffer Panels** (`wxUI/buffers/`): Panel implementations for each buffer type
|
||||||
|
- **Dialogs** (`wxUI/dialogs/`): Post composition, settings, user profiles, filters
|
||||||
|
|
||||||
|
#### 5. Pub/Sub Event System
|
||||||
|
Decoupled communication using PyPubSub 4.0.3.
|
||||||
|
|
||||||
|
Key events:
|
||||||
|
- `mastodon.status_received`: New post received via streaming
|
||||||
|
- `mastodon.status_updated`: Post edited
|
||||||
|
- `mastodon.notification_received`: New notification
|
||||||
|
- `mastodon.conversation_received`: New DM
|
||||||
|
|
||||||
|
Event flow:
|
||||||
|
1. Streaming listener receives API event
|
||||||
|
2. Publishes to topic via `pub.sendMessage()`
|
||||||
|
3. mainController subscribes to topics and routes to appropriate buffer
|
||||||
|
4. Buffer updates its display
|
||||||
|
|
||||||
|
#### 6. Session Manager (`src/sessionmanager/`)
|
||||||
|
Manages session lifecycle (creation, configuration, activation, deletion).
|
||||||
|
|
||||||
|
- `sessionManager.py`: UI for managing multiple accounts
|
||||||
|
- `manager.py`: Persists session list to global config
|
||||||
|
- Handles OAuth2 authorization flow for new accounts
|
||||||
|
- Loads saved sessions on startup
|
||||||
|
|
||||||
|
#### 7. Configuration System (`src/config.py`, `src/config_utils.py`)
|
||||||
|
Hierarchical configuration with defaults and user overrides.
|
||||||
|
|
||||||
|
- Global config: `config/app-configuration.conf` (defaults in `src/app-configuration.defaults`)
|
||||||
|
- Session configs: `config/{session_id}/session.conf` (defaults in `src/mastodon.defaults`)
|
||||||
|
- Keymaps in `src/keymaps/`
|
||||||
|
- Sound packs in `src/sounds/`
|
||||||
|
|
||||||
|
**Path Management** (`src/paths.py`):
|
||||||
|
- Portable mode: Config/logs in application directory
|
||||||
|
- Installed mode: Config/logs in AppData
|
||||||
|
- Detects installation by presence of `Uninstall.exe`
|
||||||
|
|
||||||
|
#### 8. Accessibility Features
|
||||||
|
Built for screen reader users from the ground up.
|
||||||
|
|
||||||
|
- `accessible_output2`: Multi-screen reader support (NVDA, JAWS, SAPI, etc.)
|
||||||
|
- `sound_lib`: Accessible audio playback with spatial audio
|
||||||
|
- `platform_utils`: OS-specific accessibility hooks
|
||||||
|
- `output.py`: Unified interface for speech output
|
||||||
|
- `sound.py`: Sound system with volume control and sound pack management
|
||||||
|
|
||||||
|
#### 9. Keyboard Handling (`src/keyboard_handler/`)
|
||||||
|
Cross-platform keyboard input with global hotkey support.
|
||||||
|
|
||||||
|
- `wx_handler.py`: wxPython integration
|
||||||
|
- `global_handler.py`: System-wide hotkeys
|
||||||
|
- Platform implementations: `windows.py`, `osx.py`, `linux.py`
|
||||||
|
- `keystrokeEditor/`: UI for customizing shortcuts
|
||||||
|
|
||||||
|
### Application Initialization Flow
|
||||||
|
|
||||||
|
From `src/main.py`:
|
||||||
|
1. Setup logging to temp directory, then move to permanent location
|
||||||
|
2. Initialize language handler
|
||||||
|
3. Load global configuration
|
||||||
|
4. Setup sound system
|
||||||
|
5. Setup accessibility output
|
||||||
|
6. Initialize session manager
|
||||||
|
7. Load saved sessions or prompt for account creation
|
||||||
|
8. Create main controller
|
||||||
|
9. Start main event loop
|
||||||
|
|
||||||
|
### Data Flow Patterns
|
||||||
|
|
||||||
|
#### Real-time Update Flow
|
||||||
|
```
|
||||||
|
Mastodon Streaming API
|
||||||
|
→ sessions/mastodon/streaming.py (StreamListener)
|
||||||
|
→ pub.sendMessage("mastodon.status_received", ...)
|
||||||
|
→ controller/mainController.py (subscriber)
|
||||||
|
→ buffer.add_new_item()
|
||||||
|
→ compose_function(item)
|
||||||
|
→ wxUI update
|
||||||
|
```
|
||||||
|
|
||||||
|
#### User Action Flow
|
||||||
|
```
|
||||||
|
Keyboard input
|
||||||
|
→ wx event handler
|
||||||
|
→ buffer.get_event()
|
||||||
|
→ buffer action method (e.g., open_status())
|
||||||
|
→ session.api_call()
|
||||||
|
→ UI update or pub/sub event
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Periodic Update Flow
|
||||||
|
```
|
||||||
|
RepeatingTimer (every N seconds)
|
||||||
|
→ mainController calls buffer.start_stream()
|
||||||
|
→ session.get_timeline_data()
|
||||||
|
→ buffer.put_items_on_list()
|
||||||
|
→ compose_function for each item
|
||||||
|
→ wxUI list control update
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Patterns and Conventions
|
||||||
|
|
||||||
|
### Compose Functions
|
||||||
|
Buffers use compose functions to render API objects as user-readable strings. Located in `sessions/mastodon/compose.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
compose_function(item, db, relative_times, show_screen_names=False, session=None)
|
||||||
|
# Returns a string representation of the item for display
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Decorators
|
||||||
|
Sessions use decorators to enforce prerequisites:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@baseSession._require_login
|
||||||
|
def post_status(self, text):
|
||||||
|
# Only executes if self.logged == True
|
||||||
|
pass
|
||||||
|
|
||||||
|
@baseSession._require_configuration
|
||||||
|
def get_timeline(self):
|
||||||
|
# Only executes if self.settings != None
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Buffer Naming Convention
|
||||||
|
Buffers have both a `name` (internal identifier) and `account` (associated username):
|
||||||
|
- `name`: e.g., "home_timeline", "mentions", "notifications"
|
||||||
|
- `account`: e.g., "user@mastodon.social"
|
||||||
|
- Buffers are uniquely identified by (name, account) tuple
|
||||||
|
|
||||||
|
### Configuration Hierarchy
|
||||||
|
1. Default values in `src/*.defaults` files
|
||||||
|
2. User overrides in `config/*.conf` files
|
||||||
|
3. Runtime modifications via settings dialogs
|
||||||
|
4. Written back to user config files on change
|
||||||
|
|
||||||
|
## Important Caveats
|
||||||
|
|
||||||
|
### Platform-Specific Code
|
||||||
|
- VLC paths must be set via environment variables when running from source (see `main.py`)
|
||||||
|
- Windows-specific: pywin32, win-inet-pton, winpaths dependencies
|
||||||
|
- Accessibility output works best on Windows with NVDA/JAWS
|
||||||
|
|
||||||
|
### Threading and Event Handling
|
||||||
|
- API calls often wrapped in `call_threaded()` to avoid blocking UI
|
||||||
|
- Streaming runs in background thread and publishes to main thread via pub/sub
|
||||||
|
- wx events must be handled on main thread
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
- Sessions must be logged in before buffer creation
|
||||||
|
- Buffers maintain references to sessions via `self.session`
|
||||||
|
- Destroying a session should destroy all associated buffers
|
||||||
|
- Session settings auto-save on write via `settings.write()`
|
||||||
|
|
||||||
|
### Buffer Visibility
|
||||||
|
- Buffers have `invisible` flag for internal/system buffers
|
||||||
|
- Main controller distinguishes between visible buffers (shown in tree) and invisible buffers (used for data access)
|
||||||
|
- Empty buffers serve as account placeholders in tree structure
|
||||||
|
|
||||||
|
### Logging and Debugging
|
||||||
|
- Logs written to temp directory on startup, then moved to permanent location
|
||||||
|
- Binary builds redirect stdout/stderr to `logs/` directory
|
||||||
|
- Source builds use console output
|
||||||
|
- Use `logging.getLogger("module.name")` pattern throughout
|
||||||
|
|
||||||
|
## Build System Details
|
||||||
|
|
||||||
|
### cx_Freeze Configuration (`src/setup.py`)
|
||||||
|
- Target: Win32GUI (suppresses console window)
|
||||||
|
- Includes: keymaps, locales, sounds, documentation, icon, config defaults
|
||||||
|
- Architecture-specific: Loads x86 or x64 dependencies from windows-dependencies submodule
|
||||||
|
- Special handling for enchant dictionaries, VLC plugins, VC++ redistributables
|
||||||
|
|
||||||
|
### NSIS Installer (`scripts/twblue.nsi`)
|
||||||
|
- Expects binary distribution in `scripts/twblue64/`
|
||||||
|
- Creates Start Menu shortcuts, Desktop shortcut (optional)
|
||||||
|
- Registers uninstaller
|
||||||
|
- Checks for running instances before install/uninstall
|
||||||
|
|
||||||
|
### CI/CD (`.github/workflows/release.yml`)
|
||||||
|
- Triggers on version tags (v20*)
|
||||||
|
- Builds on Windows-latest with Python 3.10
|
||||||
|
- Creates both installer (EXE) and portable (ZIP) distributions
|
||||||
|
- Uploads to GitHub releases
|
||||||
|
|
||||||
|
## Mastodon API Integration
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
OAuth2 flow implemented in `sessions/mastodon/session.py`:
|
||||||
|
1. Create application credentials for instance
|
||||||
|
2. Request OAuth authorization URL
|
||||||
|
3. User authorizes in browser
|
||||||
|
4. Exchange code for access token
|
||||||
|
5. Store credentials in session config
|
||||||
|
|
||||||
|
### API Client
|
||||||
|
Uses Mastodon.py 2.1.4 library:
|
||||||
|
- Instance created with base URL and access token
|
||||||
|
- Methods: `status_post()`, `timeline()`, `account()`, etc.
|
||||||
|
- Rate limiting handled by library
|
||||||
|
- Supports multiple instances simultaneously
|
||||||
|
|
||||||
|
### Streaming API
|
||||||
|
Real-time updates via `sessions/mastodon/streaming.py`:
|
||||||
|
- Inherits from `Mastodon.StreamListener`
|
||||||
|
- Connects to user, public, or hashtag streams
|
||||||
|
- Runs in background thread
|
||||||
|
- Events published to main thread via pub/sub
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
TWBlue supports 23 languages:
|
||||||
|
- Translation files in `src/locales/{lang}/LC_MESSAGES/twblue.mo`
|
||||||
|
- Uses gettext with `_()` function throughout codebase
|
||||||
|
- Language selection in settings, stored in global config
|
||||||
|
- Babel for extraction and compilation
|
||||||
|
- Weblate for translation management
|
||||||
46
README.md
46
README.md
@@ -71,11 +71,55 @@ Now that you have installed all these packages, you can run TW Blue from source
|
|||||||
|
|
||||||
If necessary, change the first part of the command to reflect the location of your python executable.
|
If necessary, change the first part of the command to reflect the location of your python executable.
|
||||||
|
|
||||||
|
### Development bootstrap (Windows / PowerShell)
|
||||||
|
|
||||||
|
If you are starting fresh in this repository, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/bootstrap-dev.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This script initializes submodules, creates `.venv` (unless you opt out), and installs dependencies.
|
||||||
|
|
||||||
|
Useful options:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Recreate virtual environment from scratch
|
||||||
|
./scripts/bootstrap-dev.ps1 -RecreateVenv
|
||||||
|
|
||||||
|
# Upgrade pip tooling before installing requirements
|
||||||
|
./scripts/bootstrap-dev.ps1 -UpgradePip
|
||||||
|
|
||||||
|
# Use system Python instead of .venv
|
||||||
|
./scripts/bootstrap-dev.ps1 -SystemPython
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running tests (Windows / PowerShell)
|
||||||
|
|
||||||
|
After bootstrap, run tests with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/run-tests.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful options:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Run only Bluesky tests
|
||||||
|
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
|
||||||
|
|
||||||
|
# Run only a specific test file
|
||||||
|
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski/test_blueski_quotes.py -PytestArgs "-q"
|
||||||
|
|
||||||
|
# Use system Python instead of .venv
|
||||||
|
./scripts/run-tests.ps1 -SystemPython
|
||||||
|
```
|
||||||
|
|
||||||
### Generating the documentation
|
### Generating the documentation
|
||||||
|
|
||||||
To generate the documentation in html format, navigate to the doc folder inside this repo. After that, run these commands:
|
To generate the documentation in html format, navigate to the doc folder inside this repo. After that, run these commands:
|
||||||
|
|
||||||
`python document_importer.py`
|
`python documentation_importer.py`
|
||||||
`python generator.py`
|
`python generator.py`
|
||||||
|
|
||||||
The documentation will be generated, placing each language in a separate folder in the doc directory. Move these folders (for example `de`, `en`, `es`, `fr`, `it`, ...) to `src/documentation`, creating the directory if necessary.
|
The documentation will be generated, placing each language in a separate folder in the doc directory. Move these folders (for example `de`, `en`, `es`, `fr`, `it`, ...) to `src/documentation`, creating the directory if necessary.
|
||||||
|
|||||||
121
doc/changelog.md
121
doc/changelog.md
@@ -1,7 +1,128 @@
|
|||||||
TWBlue Changelog
|
TWBlue Changelog
|
||||||
|
|
||||||
|
## Changes in version 2024.X.X (Upcoming - ATProtoSocial Integration)
|
||||||
|
|
||||||
|
This version introduces comprehensive support for the AT Protocol (ATProto), enabling users to connect to and interact with Bluesky accounts.
|
||||||
|
|
||||||
|
* Core:
|
||||||
|
* **New Protocol Support**: Added ATProtoSocial (Bluesky) as a new session type.
|
||||||
|
* **Session Management**: Users can add Bluesky accounts using their handle and an App Password. Includes session creation, loading, and management through the Session Manager.
|
||||||
|
* **UI Adaptation**:
|
||||||
|
* Menus (e.g., "Post", "User Actions") dynamically update labels and available actions based on whether an ATProtoSocial session is active (e.g., "Tweet" becomes "Post", "Retweet" becomes "Repost", "Favorite" becomes "Like").
|
||||||
|
* New compose dialog (`src/wxUI/dialogs/composeDialog.py`) created to be more generic and configurable by session type, supporting features like character limits, media attachments (images with alt text), language selection, content warnings, and quoting specific to Bluesky.
|
||||||
|
* New user profile dialog (`src/wxUI/dialogs/atprotosocial/showUserProfile.py`) for displaying Bluesky user details and performing actions.
|
||||||
|
* New UI panels (`src/wxUI/buffers/atprotosocial/panels.py`) for displaying Home timelines, User timelines, Notifications, and User Lists (Followers/Following) for Bluesky.
|
||||||
|
* ATProtoSocial (Bluesky) Features:
|
||||||
|
* **Authentication**: Secure login using user handle and App Passwords.
|
||||||
|
* **Posting**:
|
||||||
|
* Create posts (skeets) with text.
|
||||||
|
* Attach images (up to 4, with alt text).
|
||||||
|
* Specify post language(s).
|
||||||
|
* Add content warnings (sensitive content labels).
|
||||||
|
* Quote other posts.
|
||||||
|
* Reply to posts.
|
||||||
|
* **Timelines**:
|
||||||
|
* View Home timeline (posts from followed users), with support for loading newer and older posts.
|
||||||
|
* View other users' timelines (their posts and replies).
|
||||||
|
* **Notifications**:
|
||||||
|
* Fetch and display notifications for likes, reposts, follows, mentions, replies, and quotes.
|
||||||
|
* Notifications are displayed in a dedicated buffer and trigger desktop alerts.
|
||||||
|
* **User Actions**:
|
||||||
|
* View user profiles (display name, handle, bio, counts, etc.).
|
||||||
|
* Follow / Unfollow users.
|
||||||
|
* Mute / Unmute users.
|
||||||
|
* Block / Unblock users.
|
||||||
|
* **User Interaction**:
|
||||||
|
* Like / Unlike posts.
|
||||||
|
* Repost / Unrepost posts (Unrepost might be deleting the repost record).
|
||||||
|
* **User Discovery**:
|
||||||
|
* Search for users by handle or display name.
|
||||||
|
* View lists of followers and accounts a user is following.
|
||||||
|
* **Content Display**:
|
||||||
|
* Posts are formatted for display, showing author, text, timestamp, embedded media (images, quotes, external links with placeholders), reply/repost/like counts, and CWs.
|
||||||
|
* Notifications are formatted for display in their buffer.
|
||||||
|
* Developer / Internal:
|
||||||
|
* New session module: `sessions.atprotosocial` (Session, Utils, Compose, Streaming placeholders).
|
||||||
|
* New controller module: `controller.atprotosocial` (Handler, UserList, etc.).
|
||||||
|
* Extensive use of the `atproto` Python SDK for Bluesky API interactions.
|
||||||
|
|
||||||
## changes in this version
|
## changes in this version
|
||||||
|
|
||||||
|
* Core:
|
||||||
|
* Expanded the keystroke editor actions list. Now, many previously hidden or unassignable actions are available to be mapped to custom keyboard shortcuts.
|
||||||
|
* Mastodon:
|
||||||
|
* Added support for sending quoted posts! You can now quote other users' posts from the context menu or the new Boost dialog. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||||
|
* Fixed an issue where HTML entities were not decoded when editing a post. ([#893](https://github.com/mcv-software/twblue/issues/893))
|
||||||
|
|
||||||
|
## Changes in version 2026.01.13
|
||||||
|
|
||||||
|
In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts.
|
||||||
|
|
||||||
|
* Core:
|
||||||
|
* Fixed a critical issue where buffers were not visible on screen in certain configurations. Now the main window maximizes correctly and visual fixes have been applied to ensure content is accessible. ([#886](https://github.com/mcv-software/twblue/issues/886))
|
||||||
|
* Keyboard shortcut improvements: Several shortcuts have been added and fixed to improve efficiency and avoid conflicts:
|
||||||
|
* New shortcuts for the autocomplete users manager and menu bar items. ([#842](https://github.com/mcv-software/twblue/issues/842))
|
||||||
|
* Added a shortcut for the "Restore Template" button in the template editor dialog. ([#841](https://github.com/mcv-software/twblue/issues/841))
|
||||||
|
* New shortcuts for user list and poll dialogs.
|
||||||
|
* Resolved a conflict with the 's' key shortcut used for seeking media.
|
||||||
|
* Updated the shortcut for marking an account as a "Bot" to avoid conflict with the biography field.
|
||||||
|
* Mastodon:
|
||||||
|
* **Post Editing:** It is finally possible to edit Mastodon posts from TWBlue! You can now correct errors in your posts. ([#859](https://github.com/mcv-software/twblue/issues/859))
|
||||||
|
* Safety warning: if you edit a post containing a poll, votes will be reset.
|
||||||
|
* Polls are now correctly displayed as attachments within the edit dialog.
|
||||||
|
* **Scheduled Posts:** It is now possible to schedule your posts to be published at a later time!
|
||||||
|
* Added a "Schedule post" checkbox to the post dialog with date and time pickers.
|
||||||
|
* Implemented validation to ensure posts are scheduled at least 5 minutes in the future, as required by Mastodon.
|
||||||
|
* The default time is automatically set to 6 minutes in the future for convenience.
|
||||||
|
* **Quoted Posts:** Significantly improved the reading and display of quoted posts. TWBlue now structures and reads this content more clearly. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||||
|
* **Content Cleaning:** Implemented a more robust HTML filter to remove junk elements or unnecessary CSS classes from post text, offering a cleaner reading experience.
|
||||||
|
* **Mute Conversation:** Enhanced the "Mute Conversation" feature.
|
||||||
|
* Posts from muted conversations will now be visually hidden from the Home timeline immediately upon muting, ensuring a cleaner experience.
|
||||||
|
* Added a new invisible shortcut to toggle mute on the focused conversation: `Alt+Windows+Shift+Delete` (Default) or `Control+Alt+Windows+Backspace` (Windows 10/11).
|
||||||
|
* The action is also available in the context menu of the post.
|
||||||
|
* **Announcements:** Added support for viewing server announcements.
|
||||||
|
* New dedicated buffer for "Announcements" where you can read instance-wide news.
|
||||||
|
* Added ability to dismiss (mark as read) announcements directly from the buffer.
|
||||||
|
|
||||||
|
## Changes in version 2025.3.8
|
||||||
|
|
||||||
|
In this version, we have focused on providing initial support for Mastodon filters and pinned posts. From TWBlue, it is now possible to initially use filters for posts in most buffers, as well as manage them (create, edit, and delete filters, in addition to adding keywords). A new variable has also been added for post templates in the invisible interface that allows displaying whether a post has been pinned by its author.
|
||||||
|
|
||||||
|
* Mastodon:
|
||||||
|
* Added filters support to TWBlue. Filters are only implemented in posts for the moment. TWBlue will, depending in the selected settings, hide behind a content warning or completely ignore a post based on filters. Also it is possible to add, delete or edit filters from the buffer menu in the menu bar.
|
||||||
|
* A language selector has been added for posting in TWBlue. It is now possible to choose the language in which a post will be made, which will be useful for content filtering and other language-dependent features. The default language can be chosen based on your Mastodon account’s language, the language of the post you’re replying to, or, if no automatic selection is possible, TWBlue’s own language will be used by default.
|
||||||
|
* TWBlue now supports announcing (via a new template variable for posts) pinned posts. You can edit your posts template and use the $pinned variable. When reading the post, TWBlue will indicate if the post is pinned. Also, when loading an user timeline, pinned posts will be loaded at the top or bottom of the buffer according to local settings.
|
||||||
|
* TWBlue should be able to display all posts in the post displayer dialog.
|
||||||
|
* reading long posts in the graphical user interface should work better.
|
||||||
|
|
||||||
|
## Changes in version 2024.5.23
|
||||||
|
|
||||||
|
* Core:
|
||||||
|
* The way sessions are named has been changed. Now the account is indicated first, followed by the social network it belongs to.
|
||||||
|
* An option has been added to the global options dialog that allows for the reading of long posts in the graphical interface. This is especially useful since, by default, the graphical interface can only display a limited number of characters in the post.
|
||||||
|
* Some options that are no longer necessary in the application have been removed from the global settings dialog.
|
||||||
|
* Mastodon:
|
||||||
|
* fixed an error that caused TWBlue to not display some posts correctly.
|
||||||
|
* Fixed name for community timelines when created during startup. Now it should be clear if it's a federated or local timeline.
|
||||||
|
* Defined shortcuts to fields on the update profile dialog so it will be easier to navigate.
|
||||||
|
* Now it is possible to load conversations directly from community timelines.
|
||||||
|
|
||||||
|
## Changes in version 2024.5.19
|
||||||
|
|
||||||
|
In this version of TWBlue, which is being released several months after the previous one, we focused on adding initial support for GoToSocial-type networks. GoToSocial is a server for creating decentralized networks similar to Mastodon. Its API is very similar but retains some differences. In this version, TWBlue can be used to log into GoToSocial accounts, although there will be some features, such as the Streaming API and Markdown support, that are not yet functional. Another significant addition is support for creating community timelines, which will allow you to load the local and public timeline of remote instances. This is useful if your instance does not federate directly with them, as it will allow you to see posts from other communities and interact directly with them. Finally, the translation module has been rewritten; it now supports using LibreTranslate by default and DeepL, for which an API key is required. Below is the detailed list of changes:
|
||||||
|
|
||||||
|
* Core:
|
||||||
|
* Added Initial Support to GoToSocial. Some features are not fully implemented yet, although GoToXocial instances should be able to be used as normal sessions in TWBlue. Streaming, poll options and markdown are not supported but planned for the near future.
|
||||||
|
* The translation module has been rewritten. Now, instead of offering translations with Google Translator, the user can choose between [LibreTranslate,](https://github.com/LibreTranslate/LibreTranslate) which requires no configuration thanks to the [instance of the NVDA Spanish community;](https://translate.nvda.es) or translate using [DeepL,](https://deepl.com) for which it is necessary to create an account on DeepL and [subscribe to a DeepL API Free plan](https://support.deepl.com/hc/en-us/articles/360021200939-DeepL-API-Free) to obtain the API key which can be used to translate up to 500000 characters every month. The API key can be entered in the global options dialog, under a new tab called translation services. When translating a text, the translation engine can be changed. When changing the translation engine, the target language must be selected again before translation takes place.
|
||||||
|
* TWBlue should be able to switch to Windows 11 Keymap when running under Windows 11. ([#494](https://github.com/mcv-software/twblue/issues/494))
|
||||||
|
* Mastodon:
|
||||||
|
* Added support for viewing communities: A community timeline is the local or public timeline of another instance. This is especially useful when the instance one is part of does not federate with other remote instances. The posts displayed are only those that are shared publicly. It is possible to interact with the posts from community timelines, but it should be noted that TWBlue will take some time to retrieve the post one wishes to interact with.
|
||||||
|
* When viewing a post, a button displays the number of boosts and times it has been added to favorites. Clicking on that button will open a list of users who have interacted with the post. From that list, it is possible to view profiles and perform common user actions.
|
||||||
|
* Now it is possible to mute conversations in Mastodon sessions. To do this, there is a button that can be called "Mute" or "Unmute Conversation" in the dialog to display the post. Conversations that have been muted will not generate notifications or mentions when they receive new replies. Only conversations that you are a part of can be muted.
|
||||||
|
* Fixed an error that caused TWBlue to be unable to properly display the user action dialog from the followers or following buffer. ([#575](https://github.com/mcv-software/twblue/issues/575))
|
||||||
|
|
||||||
|
## changes in version 2024.01.05
|
||||||
|
|
||||||
* Core:
|
* Core:
|
||||||
* The TWBlue website will no longer be available on the twblue.es domain. Beginning in January 2024, TWBlue will live at https://twblue.mcvsoftware.com. Also, we will start releasing versions on [gitHub releases](https://github.com/mcv-software/twblue/releases) so it will be easier to track specific versions.
|
* The TWBlue website will no longer be available on the twblue.es domain. Beginning in January 2024, TWBlue will live at https://twblue.mcvsoftware.com. Also, we will start releasing versions on [gitHub releases](https://github.com/mcv-software/twblue/releases) so it will be easier to track specific versions.
|
||||||
* As of the first release of TWBlue in 2024, we will officially stop generating 32-bit (X86) compatible binaries due to the increasing difficulty of generating versions compatible with this architecture in modern Python.
|
* As of the first release of TWBlue in 2024, we will officially stop generating 32-bit (X86) compatible binaries due to the increasing difficulty of generating versions compatible with this architecture in modern Python.
|
||||||
|
|||||||
1543
doc/locales/bg/LC_MESSAGES/twblue-documentation.po
Normal file
1543
doc/locales/bg/LC_MESSAGES/twblue-documentation.po
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,3 +29,14 @@ Global settings and session settings
|
|||||||
++++++++++++++++++++++++++++++++++++++++++++++
|
++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
|
TWBlue has two different configuration dialogs: the global configuration dialog, which affects how TWBlue works for all sessions, and the session configuration dialog, which only affects how the current session works. You will find specific information about the session settings dialog for Twitter and Mastodon in its corresponding chapter in this guide.
|
||||||
|
|
||||||
|
Blueski / Bluesky Specific Terms
|
||||||
|
++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
When using the Blueski (Bluesky) integration, you might encounter these terms:
|
||||||
|
|
||||||
|
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social`` or a custom domain like ``@yourname.com``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
|
||||||
|
* **App Password**: A specific password you generate within your Bluesky account settings (usually under Settings -> Advanced -> App passwords) for use with third-party applications like TWBlue. This is more secure than using your main account password, as each App Password can be revoked individually.
|
||||||
|
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It typically starts with ``did:plc:``. Your DID doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly for user interaction.
|
||||||
|
* **Skyline**: This is the term Bluesky uses for your main home timeline, showing posts from people you follow.
|
||||||
|
* **Skeet**: An informal term for a post on Bluesky (akin to a "tweet" on Twitter).
|
||||||
59
documentation/source/blueski.rst
Normal file
59
documentation/source/blueski.rst
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
.. _blueski_bluesky:
|
||||||
|
|
||||||
|
**************************************
|
||||||
|
Blueski (Bluesky) Integration
|
||||||
|
**************************************
|
||||||
|
|
||||||
|
TWBlue now supports the AT Protocol (ATProto), the decentralized social networking protocol that powers Bluesky. This allows you to interact with your Bluesky account directly within TWBlue.
|
||||||
|
|
||||||
|
Adding a Blueski Account
|
||||||
|
===============================
|
||||||
|
|
||||||
|
To connect your Bluesky account to TWBlue, you will need your user **handle** and an **App Password**.
|
||||||
|
|
||||||
|
1. **User Handle**: This is your unique Bluesky identifier, often in the format ``@username.bsky.social`` or a custom domain you've configured (e.g., ``@yourname.com``).
|
||||||
|
2. **App Password**: Bluesky uses App Passwords for third-party applications like TWBlue instead of your main account password. You need to generate an App Password from your Bluesky account settings.
|
||||||
|
* Go to Bluesky Settings (usually accessible from the Bluesky app or website).
|
||||||
|
* Navigate to the "App passwords" section (this might be under "Advanced" or "Security").
|
||||||
|
* Generate a new App Password. Give it a descriptive name (e.g., "TWBlue").
|
||||||
|
* Copy the generated App Password immediately. It will usually only be shown once.
|
||||||
|
|
||||||
|
Once you have your handle and the App Password:
|
||||||
|
|
||||||
|
1. Open TWBlue and go to the Session Manager (Application Menu -> Manage accounts).
|
||||||
|
2. Click on "New account".
|
||||||
|
3. Select "Blueski (Bluesky)" from the menu.
|
||||||
|
4. A dialog will prompt you to confirm that you want to authorize your account. Click "Yes".
|
||||||
|
5. You will then be asked for your Bluesky Handle. Enter your full handle (e.g., ``@username.bsky.social`` or ``username.bsky.social``).
|
||||||
|
6. Next, you will be asked for the App Password you generated. Enter it carefully.
|
||||||
|
7. If the credentials are correct, TWBlue will log in to your Bluesky account, and the new session will be added to your accounts list.
|
||||||
|
|
||||||
|
Key Features
|
||||||
|
============
|
||||||
|
|
||||||
|
Once your Blueski account is connected, you can use the following features in TWBlue:
|
||||||
|
|
||||||
|
* **Posting**: Create new posts (often called "skeets") with text, images, and specify language.
|
||||||
|
* **Timelines**:
|
||||||
|
* **Discover (algorithmic)**: A home feed curated by Bluesky.
|
||||||
|
* **Following (chronological)**: View posts from users you follow in order.
|
||||||
|
* **User Timelines**: View posts from specific users.
|
||||||
|
* **Mentions & Replies**: These will appear in your Notifications.
|
||||||
|
* **Notifications**: Receive notifications for likes, reposts, follows, mentions, replies, and quotes.
|
||||||
|
* **User Actions**:
|
||||||
|
* Follow and unfollow users.
|
||||||
|
* Mute and unmute users.
|
||||||
|
* Block and unblock users (blocking is done on your PDS/server).
|
||||||
|
* **Quoting Posts**: Quote other users' posts when you create a new post.
|
||||||
|
* **User Search**: Search for users by their handle or display name.
|
||||||
|
* **Content Warnings**: Create posts with content warnings (sensitive content labels).
|
||||||
|
|
||||||
|
Basic Concepts for Blueski
|
||||||
|
==================================
|
||||||
|
|
||||||
|
* **DID (Decentralized Identifier)**: A unique, permanent identifier for users and data on the AT Protocol. It doesn't change even if your handle does. You generally won't need to interact with DIDs directly in TWBlue, as handles are used more commonly.
|
||||||
|
* **Handle**: Your user-facing address on Bluesky (e.g., ``@username.bsky.social``). This is what you use to log in with an App Password in TWBlue. Handles can be changed, but your DID remains the same.
|
||||||
|
* **App Password**: A specific password you generate within your Bluesky account settings for use with third-party applications like TWBlue. This is more secure than using your main account password.
|
||||||
|
* **PDS (Personal Data Server)**: Where your account data is stored on the AT Protocol network. Most users are on the main Bluesky PDS (bsky.social), but you could potentially use a different one. TWBlue will typically connect to the default Bluesky PDS.
|
||||||
|
|
||||||
|
Further details on specific actions can be found in the relevant sections of this documentation. As Bluesky and the AT Protocol evolve, TWBlue will aim to incorporate new features and refinements.
|
||||||
@@ -16,6 +16,7 @@ This is the user guide for the latest available version of TWBlue. The purpose o
|
|||||||
system_requirements
|
system_requirements
|
||||||
installation
|
installation
|
||||||
basic_concepts
|
basic_concepts
|
||||||
|
blueski
|
||||||
usage
|
usage
|
||||||
global_settings
|
global_settings
|
||||||
credits
|
credits
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
In this version, we have focused on expanding content management capabilities within Mastodon. It is now possible to edit sent posts and schedule them for future publication. Additionally, support for reading quoted posts has been implemented, and a new buffer for server announcements is available. On the Core side, visual stability has been prioritized to ensure proper window display, along with an expansion of keyboard shortcuts.
|
||||||
|
|
||||||
* Core:
|
* Core:
|
||||||
* The TWBlue website will no longer be available on the twblue.es domain. Beginning in January 2024, TWBlue will live at https://twblue.mcvsoftware.com. Also, we will start releasing versions on [gitHub releases](https://github.com/mcv-software/twblue/releases) so it will be easier to track specific versions.
|
* Fixed a critical issue where buffers were not visible on screen in certain configurations. Now the main window maximizes correctly and visual fixes have been applied to ensure content is accessible. ([#886](https://github.com/mcv-software/twblue/issues/886))
|
||||||
* As of the first release of TWBlue in 2024, we will officially stop generating 32-bit (X86) compatible binaries due to the increasing difficulty of generating versions compatible with this architecture in modern Python.
|
* Keyboard shortcut improvements: Several shortcuts have been added and fixed to improve efficiency and avoid conflicts:
|
||||||
* TWBlue should be more reliable when checking for updates.
|
* New shortcuts for the autocomplete users manager and menu bar items. ([#842](https://github.com/mcv-software/twblue/issues/842))
|
||||||
* If running from source, automatic updates will not be checked as this works only for distribution. ([#540](https://github.com/MCV-Software/TWBlue/pull/540))
|
* Added a shortcut for the "Restore Template" button in the template editor dialog. ([#841](https://github.com/mcv-software/twblue/issues/841))
|
||||||
* Fixed the 'report an error' item in the help menu. Now this item redirects to our gitHub issue tracker. ([#524](https://github.com/MCV-Software/TWBlue/pull/524))
|
* New shortcuts for user list and poll dialogs.
|
||||||
|
* Resolved a conflict with the 's' key shortcut used for seeking media.
|
||||||
|
* Updated the shortcut for marking an account as a "Bot" to avoid conflict with the biography field.
|
||||||
* Mastodon:
|
* Mastodon:
|
||||||
* Implemented actions for the notifications buffer on a mastodon instance. Actions can be performed from the contextual menu on every notification, or by using invisible keystrokes. ([#517](https://github.com/mcv-software/twblue/issues/517))
|
* **Post Editing:** It is finally possible to edit Mastodon posts from TWBlue! You can now correct errors in your posts. ([#859](https://github.com/mcv-software/twblue/issues/859))
|
||||||
* Implemented update profile dialog. ([#547](https://github.com/MCV-Software/TWBlue/pull/547))
|
* Safety warning: if you edit a post containing a poll, votes will be reset.
|
||||||
* It is possible to display an user profile from the user menu within the menu bar, or by using the invisible keystroke for user details. ([#555](https://github.com/MCV-Software/TWBlue/pull/555))
|
* Polls are now correctly displayed as attachments within the edit dialog.
|
||||||
* Added possibility to vote in polls. This is mapped to Alt+Win+Shift+V in the invisible keymaps for windows 10/11.
|
* **Scheduled Posts:** It is now possible to schedule your posts to be published at a later time!
|
||||||
* Added posts search. Take into account that Mastodon instances should be configured with full text search enabled. Search for posts only include posts the logged-in user has interacted with. ([#541](https://github.com/MCV-Software/TWBlue/pull/541))
|
* Added a "Schedule post" checkbox to the post dialog with date and time pickers.
|
||||||
* Added user autocompletion settings in account settings dialog, so it is possible to ask TWBlue to scan mastodon accounts and add people from followers and following buffers. For now, user autocompletion can be used only when composing new posts or replies.
|
* Implemented validation to ensure posts are scheduled at least 5 minutes in the future, as required by Mastodon.
|
||||||
* TWBlue should be able to ignore deleted direct messages or messages from deleted accounts. Previously, a direct message that no longer existed in the instance caused errors when loading the direct messages buffer and could potentially affect notifications as well.
|
* The default time is automatically set to 6 minutes in the future for convenience.
|
||||||
* TWBlue should be able to ignore notifications from deleted accounts or posts.
|
* **Quoted Posts:** Significantly improved the reading and display of quoted posts. TWBlue now structures and reads this content more clearly. ([#860](https://github.com/mcv-software/twblue/issues/860))
|
||||||
|
* **Content Cleaning:** Implemented a more robust HTML filter to remove junk elements or unnecessary CSS classes from post text, offering a cleaner reading experience.
|
||||||
|
* **Mute Conversation:** Enhanced the "Mute Conversation" feature.
|
||||||
|
* Posts from muted conversations will now be visually hidden from the Home timeline immediately upon muting, ensuring a cleaner experience.
|
||||||
|
* Added a new invisible shortcut to toggle mute on the focused conversation: `Alt+Windows+Shift+Delete` (Default) or `Control+Alt+Windows+Backspace` (Windows 10/11).
|
||||||
|
* The action is also available in the context menu of the post.
|
||||||
|
* **Announcements:** Added support for viewing server announcements.
|
||||||
|
* New dedicated buffer for "Announcements" where you can read instance-wide news.
|
||||||
|
* Added ability to dismiss (mark as read) announcements directly from the buffer.
|
||||||
114
requirements.txt
114
requirements.txt
@@ -1,55 +1,59 @@
|
|||||||
wxpython
|
accessible_output2 @ git+https://github.com/accessibleapps/accessible_output2@57bda997d98e87dd78aa049e7021cf777871619b
|
||||||
pytest
|
arrow==1.4.0
|
||||||
coverage
|
attrs==25.4.0
|
||||||
wheel
|
backports.functools-lru-cache==2.0.0
|
||||||
future
|
blurhash==1.1.5
|
||||||
configobj
|
certifi==2026.2.25
|
||||||
markdown
|
chardet==7.1.0
|
||||||
requests
|
charset-normalizer==3.4.6
|
||||||
oauthlib
|
colorama==0.4.6
|
||||||
requests-oauthlib
|
configobj==5.0.9
|
||||||
requests-toolbelt
|
coverage==7.13.4
|
||||||
pypubsub
|
cx-Freeze==8.6.0
|
||||||
arrow
|
cx-Logging==3.2.1
|
||||||
python-dateutil
|
decorator==5.2.1
|
||||||
winpaths
|
demoji==1.1.0
|
||||||
PySocks
|
deepl==1.28.0
|
||||||
win_inet_pton
|
future==1.0.0
|
||||||
# Install the latest RC of this lib
|
idna==3.11
|
||||||
# see https://github.com/ssut/py-googletrans/issues/234
|
importlib-metadata==8.7.1
|
||||||
googletrans==4.0.0-rc1
|
iniconfig==2.3.0
|
||||||
idna<3,>=2.5
|
libloader @ git+https://github.com/accessibleapps/libloader@bc94811c095b2e57a036acd88660be9a33260267
|
||||||
chardet
|
libretranslatepy==2.1.4
|
||||||
urllib3
|
lief==0.15.1
|
||||||
youtube-dl
|
Markdown==3.10.2
|
||||||
python-vlc
|
Mastodon.py==2.1.4
|
||||||
pypiwin32
|
numpy==2.4.3
|
||||||
pywin32
|
oauthlib==3.3.1
|
||||||
certifi
|
packaging==25.0
|
||||||
backports.functools_lru_cache
|
pillow==12.1.1
|
||||||
cx_freeze
|
platform_utils @ git+https://github.com/accessibleapps/platform_utils@e0d79f7b399c4ea677a633d2dde9202350d62c38
|
||||||
twitter-text-parser
|
pluggy==1.6.0
|
||||||
mastodon.py
|
psutil==7.2.2
|
||||||
pyenchant
|
pyenchant==3.3.0
|
||||||
sqlitedict
|
pypiwin32==223
|
||||||
cx-Logging
|
Pypubsub==4.0.7
|
||||||
h11
|
PySocks==1.7.1
|
||||||
h2
|
pytest==9.0.2
|
||||||
hpack
|
python-dateutil==2.9.0.post0
|
||||||
hstspreload
|
python-magic-bin==0.4.14
|
||||||
httpcore
|
python-vlc==3.0.21203
|
||||||
httpx
|
pywin32==311
|
||||||
hyperframe
|
requests==2.32.5
|
||||||
rfc3986
|
requests-oauthlib==2.0.0
|
||||||
sniffio
|
requests-toolbelt==1.0.0
|
||||||
attrs
|
rfc3986==2.0.0
|
||||||
importlib-metadata
|
setuptools==82.0.1
|
||||||
numpy
|
six==1.17.0
|
||||||
pillow
|
sniffio==1.3.1
|
||||||
charset-normalizer
|
sound_lib @ git+https://github.com/accessibleapps/sound_lib@a439f0943fb95ee7b6ba24f51a686f47c4ad66b2
|
||||||
demoji
|
sqlitedict==2.1.0
|
||||||
psutil
|
twitter-text-parser==3.0.0
|
||||||
git+https://github.com/accessibleapps/libloader
|
types-python-dateutil==2.9.0.20260305
|
||||||
git+https://github.com/accessibleapps/platform_utils
|
urllib3==2.6.3
|
||||||
git+https://github.com/accessibleapps/accessible_output2
|
win-inet-pton==1.1.0
|
||||||
git+https://github.com/accessibleapps/sound_lib
|
winpaths==0.2
|
||||||
|
wxPython==4.2.5
|
||||||
|
youtube-dl==2021.12.17
|
||||||
|
zipp==3.23.0
|
||||||
|
atproto>=0.0.65
|
||||||
|
|||||||
176
scripts/bootstrap-dev.ps1
Normal file
176
scripts/bootstrap-dev.ps1
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Bootstraps a local TWBlue development environment on Windows.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script initializes git submodules, creates/uses a virtual environment,
|
||||||
|
and installs Python dependencies from requirements.txt.
|
||||||
|
It is intended to be run once when starting on the repository (or whenever
|
||||||
|
you need to rebuild your local environment).
|
||||||
|
|
||||||
|
.PARAMETER RecreateVenv
|
||||||
|
Deletes and recreates the `.venv` folder before installing dependencies.
|
||||||
|
|
||||||
|
.PARAMETER UpgradePip
|
||||||
|
Upgrades `pip`, `setuptools`, and `wheel` before installing requirements.
|
||||||
|
|
||||||
|
.PARAMETER SkipSubmodules
|
||||||
|
Skips `git submodule init` and `git submodule update --recursive`.
|
||||||
|
|
||||||
|
.PARAMETER SystemPython
|
||||||
|
Uses a detected system Python instead of creating/using `.venv`.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/bootstrap-dev.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/bootstrap-dev.ps1 -RecreateVenv -UpgradePip
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/bootstrap-dev.ps1 -SystemPython -SkipSubmodules
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[switch]$RecreateVenv,
|
||||||
|
[switch]$UpgradePip,
|
||||||
|
[switch]$SkipSubmodules,
|
||||||
|
[switch]$SystemPython
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RepoRoot {
|
||||||
|
param([string]$ScriptDir)
|
||||||
|
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PythonCandidate {
|
||||||
|
param(
|
||||||
|
[string]$Exe,
|
||||||
|
[string[]]$BaseArgs = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-PythonSpec {
|
||||||
|
param(
|
||||||
|
[string]$Exe,
|
||||||
|
[string[]]$BaseArgs = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Exe = $Exe
|
||||||
|
BaseArgs = $BaseArgs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PythonSpec {
|
||||||
|
param([string]$RepoRoot, [bool]$UseSystemPython)
|
||||||
|
|
||||||
|
if (-not $UseSystemPython) {
|
||||||
|
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||||
|
if (Test-Path $venvPython) {
|
||||||
|
return (New-PythonSpec -Exe $venvPython)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = @()
|
||||||
|
|
||||||
|
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCmd) {
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyCmd) {
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Could not find a usable Python. Install Python 3.10+ and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Python {
|
||||||
|
param(
|
||||||
|
[pscustomobject]$PythonSpec,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[string]$WorkingDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Push-Location $WorkingDirectory
|
||||||
|
try {
|
||||||
|
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Python command failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
|
||||||
|
|
||||||
|
Write-Step "Repository: $repoRoot"
|
||||||
|
|
||||||
|
if (-not $SkipSubmodules) {
|
||||||
|
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
|
||||||
|
if ($gitCmd) {
|
||||||
|
Write-Step "Initializing/updating submodules"
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
& git submodule init | Out-Host
|
||||||
|
& git submodule update --recursive | Out-Host
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Warning "Git is not available. Skipping submodule initialization."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$venvPath = Join-Path $repoRoot ".venv"
|
||||||
|
if ($RecreateVenv -and (Test-Path $venvPath)) {
|
||||||
|
Write-Step "Removing existing virtual environment"
|
||||||
|
Remove-Item -Recurse -Force $venvPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SystemPython -and -not (Test-Path (Join-Path $venvPath "Scripts\python.exe"))) {
|
||||||
|
Write-Step "Creating virtual environment in .venv"
|
||||||
|
$bootstrapPython = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $true
|
||||||
|
Invoke-Python -PythonSpec $bootstrapPython -Arguments @("-m", "venv", ".venv") -WorkingDirectory $repoRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
|
||||||
|
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
|
||||||
|
|
||||||
|
if ($UpgradePip) {
|
||||||
|
Write-Step "Upgrading pip/setuptools/wheel"
|
||||||
|
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel") -WorkingDirectory $repoRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "Installing dependencies"
|
||||||
|
Invoke-Python -PythonSpec $python -Arguments @("-m", "pip", "install", "-r", "requirements.txt") -WorkingDirectory $repoRoot
|
||||||
|
|
||||||
|
Write-Step "Bootstrap completed"
|
||||||
163
scripts/run-tests.ps1
Normal file
163
scripts/run-tests.ps1
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs TWBlue tests with minimal runtime setup.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script only executes pytest. It does not install dependencies,
|
||||||
|
create virtual environments, or initialize submodules.
|
||||||
|
|
||||||
|
Use `./scripts/bootstrap-dev.ps1` first to prepare a development environment.
|
||||||
|
|
||||||
|
.PARAMETER PytestTargets
|
||||||
|
One or more pytest target paths/files. Defaults to `src/test`.
|
||||||
|
|
||||||
|
.PARAMETER PytestArgs
|
||||||
|
Additional pytest arguments. Defaults to `-q`.
|
||||||
|
|
||||||
|
.PARAMETER SystemPython
|
||||||
|
Uses a detected system Python instead of `.venv`.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/run-tests.ps1
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/run-tests.ps1 -PytestTargets src/test/sessions/blueski -PytestArgs "-q"
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/run-tests.ps1 -SystemPython
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string[]]$PytestTargets = @("src/test"),
|
||||||
|
[string[]]$PytestArgs = @("-q"),
|
||||||
|
[switch]$SystemPython
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Write-Step {
|
||||||
|
param([string]$Message)
|
||||||
|
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-RepoRoot {
|
||||||
|
param([string]$ScriptDir)
|
||||||
|
return (Resolve-Path (Join-Path $ScriptDir "..")).Path
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-PythonCandidate {
|
||||||
|
param(
|
||||||
|
[string]$Exe,
|
||||||
|
[string[]]$BaseArgs = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Exe @BaseArgs -c "import sys; print(sys.version)" | Out-Null
|
||||||
|
return ($LASTEXITCODE -eq 0)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-PythonSpec {
|
||||||
|
param(
|
||||||
|
[string]$Exe,
|
||||||
|
[string[]]$BaseArgs = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Exe = $Exe
|
||||||
|
BaseArgs = $BaseArgs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-PythonSpec {
|
||||||
|
param([string]$RepoRoot, [bool]$UseSystemPython)
|
||||||
|
|
||||||
|
if (-not $UseSystemPython) {
|
||||||
|
$venvPython = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||||
|
if (Test-Path $venvPython) {
|
||||||
|
return (New-PythonSpec -Exe $venvPython)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = @()
|
||||||
|
|
||||||
|
$pythonCmd = Get-Command python -ErrorAction SilentlyContinue
|
||||||
|
if ($pythonCmd) {
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyCmd = Get-Command py -ErrorAction SilentlyContinue
|
||||||
|
if ($pyCmd) {
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3.10"))
|
||||||
|
$candidates += ,(New-PythonSpec -Exe "py" -BaseArgs @("-3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidate in $candidates) {
|
||||||
|
if (Test-PythonCandidate -Exe $candidate.Exe -BaseArgs $candidate.BaseArgs) {
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Could not find a usable Python. Install Python 3.10+ and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Python {
|
||||||
|
param(
|
||||||
|
[pscustomobject]$PythonSpec,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[string]$WorkingDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
Push-Location $WorkingDirectory
|
||||||
|
try {
|
||||||
|
& $PythonSpec.Exe @($PythonSpec.BaseArgs + $Arguments)
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Python command failed with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Resolve-RepoRoot -ScriptDir $scriptDir
|
||||||
|
|
||||||
|
Write-Step "Repository: $repoRoot"
|
||||||
|
|
||||||
|
$venvPython = Join-Path $repoRoot ".venv\Scripts\python.exe"
|
||||||
|
if (-not $SystemPython -and -not (Test-Path $venvPython)) {
|
||||||
|
throw "No .venv Python found. Run ./scripts/bootstrap-dev.ps1 first, or pass -SystemPython."
|
||||||
|
}
|
||||||
|
|
||||||
|
$python = Get-PythonSpec -RepoRoot $repoRoot -UseSystemPython $SystemPython.IsPresent
|
||||||
|
Write-Step "Using Python: $($python.Exe) $($python.BaseArgs -join ' ')"
|
||||||
|
|
||||||
|
Write-Step "Detecting Python architecture"
|
||||||
|
$archOutput = & $python.Exe @($python.BaseArgs + @("-c", "import struct; print('x64' if struct.calcsize('P')*8 == 64 else 'x86')"))
|
||||||
|
if ($LASTEXITCODE -ne 0 -or -not $archOutput) {
|
||||||
|
throw "Could not determine Python architecture."
|
||||||
|
}
|
||||||
|
$arch = ($archOutput | Select-Object -Last 1).Trim()
|
||||||
|
if ($arch -ne "x86" -and $arch -ne "x64") {
|
||||||
|
throw "Could not determine Python architecture (result: '$arch')."
|
||||||
|
}
|
||||||
|
|
||||||
|
$vlcPath = Join-Path $repoRoot "windows-dependencies\$arch"
|
||||||
|
if (-not (Test-Path $vlcPath)) {
|
||||||
|
throw "Could not find '$vlcPath'. Run ./scripts/bootstrap-dev.ps1 first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$env:PYTHON_VLC_MODULE_PATH = $vlcPath
|
||||||
|
if (-not ($env:PATH -split ';' | Where-Object { $_ -eq $vlcPath })) {
|
||||||
|
$env:PATH = "$vlcPath;$env:PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Step "PYTHON_VLC_MODULE_PATH=$($env:PYTHON_VLC_MODULE_PATH)"
|
||||||
|
|
||||||
|
Write-Step "Running tests"
|
||||||
|
$pytestCommandArgs = @("-m", "pytest") + $PytestTargets + $PytestArgs
|
||||||
|
Invoke-Python -PythonSpec $python -Arguments $pytestCommandArgs -WorkingDirectory $repoRoot
|
||||||
@@ -27,7 +27,7 @@ var StartMenuFolder
|
|||||||
!insertmacro MUI_PAGE_STARTMENU startmenu $StartMenuFolder
|
!insertmacro MUI_PAGE_STARTMENU startmenu $StartMenuFolder
|
||||||
!insertmacro MUI_PAGE_INSTFILES
|
!insertmacro MUI_PAGE_INSTFILES
|
||||||
!define MUI_FINISHPAGE_LINK "Visit TWBlue website"
|
!define MUI_FINISHPAGE_LINK "Visit TWBlue website"
|
||||||
!define MUI_FINISHPAGE_LINK_LOCATION "https://twblue.es"
|
!define MUI_FINISHPAGE_LINK_LOCATION "https://twblue.mcvsoftware.com"
|
||||||
!define MUI_FINISHPAGE_RUN "$INSTDIR\TWBlue.exe"
|
!define MUI_FINISHPAGE_RUN "$INSTDIR\TWBlue.exe"
|
||||||
!insertmacro MUI_PAGE_FINISH
|
!insertmacro MUI_PAGE_FINISH
|
||||||
!insertmacro MUI_UNPAGE_CONFIRM
|
!insertmacro MUI_UNPAGE_CONFIRM
|
||||||
@@ -65,7 +65,7 @@ CreateShortCut "$DESKTOP\TWBlue.lnk" "$INSTDIR\TWBlue.exe"
|
|||||||
!insertmacro MUI_STARTMENU_WRITE_BEGIN startmenu
|
!insertmacro MUI_STARTMENU_WRITE_BEGIN startmenu
|
||||||
CreateDirectory "$SMPROGRAMS\$StartMenuFolder"
|
CreateDirectory "$SMPROGRAMS\$StartMenuFolder"
|
||||||
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\TWBlue.lnk" "$INSTDIR\TWBlue.exe"
|
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\TWBlue.lnk" "$INSTDIR\TWBlue.exe"
|
||||||
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\TWBlue on the web.lnk" "http://twblue.es"
|
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\TWBlue on the web.lnk" "https://twblue.mcvsoftware.com"
|
||||||
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\Uninstall.lnk" "$INSTDIR\Uninstall.exe"
|
CreateShortCut "$SMPROGRAMS\$StartMenuFolder\Uninstall.lnk" "$INSTDIR\Uninstall.exe"
|
||||||
!insertmacro MUI_STARTMENU_WRITE_END
|
!insertmacro MUI_STARTMENU_WRITE_END
|
||||||
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
||||||
@@ -74,7 +74,7 @@ WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "U
|
|||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall" "InstallLocation" $INSTDIR
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall" "InstallLocation" $INSTDIR
|
||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall" "Publisher" "Manuel Cortéz"
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall" "Publisher" "Manuel Cortéz"
|
||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "DisplayVersion" "0.95"
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "DisplayVersion" "0.95"
|
||||||
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "URLInfoAbout" "https://twblue.es"
|
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "URLInfoAbout" "https://twblue.mcvsoftware.com"
|
||||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "VersionMajor" 0
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "VersionMajor" 0
|
||||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "VersionMinor" 0
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "VersionMinor" 0
|
||||||
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "NoModify" 1
|
WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\twblue" "NoModify" 1
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ update_period = integer(default=2)
|
|||||||
hide_gui = boolean(default=False)
|
hide_gui = boolean(default=False)
|
||||||
voice_enabled = boolean(default=False)
|
voice_enabled = boolean(default=False)
|
||||||
ask_at_exit = boolean(default=True)
|
ask_at_exit = boolean(default=True)
|
||||||
autostart = boolean(default=False)
|
read_long_posts_in_gui = boolean(default=True)
|
||||||
handle_longtweets = boolean(default=True)
|
|
||||||
use_invisible_keyboard_shorcuts = boolean(default=True)
|
use_invisible_keyboard_shorcuts = boolean(default=True)
|
||||||
play_ready_sound = boolean(default=True)
|
play_ready_sound = boolean(default=True)
|
||||||
speak_ready_msg = boolean(default=True)
|
speak_ready_msg = boolean(default=True)
|
||||||
@@ -18,9 +17,6 @@ log_level = string(default="error")
|
|||||||
load_keymap = string(default="default.keymap")
|
load_keymap = string(default="default.keymap")
|
||||||
donation_dialog_displayed = boolean(default=False)
|
donation_dialog_displayed = boolean(default=False)
|
||||||
check_for_updates = boolean(default=True)
|
check_for_updates = boolean(default=True)
|
||||||
remember_mention_and_longtweet = boolean(default=False)
|
|
||||||
longtweet = boolean(default=false)
|
|
||||||
mention_all = boolean(default=False)
|
|
||||||
no_streaming = boolean(default=False)
|
no_streaming = boolean(default=False)
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
@@ -29,3 +25,9 @@ server = string(default="")
|
|||||||
port = integer(default=8080)
|
port = integer(default=8080)
|
||||||
user = string(default="")
|
user = string(default="")
|
||||||
password = string(default="")
|
password = string(default="")
|
||||||
|
|
||||||
|
[translator]
|
||||||
|
engine=string(default="LibreTranslate")
|
||||||
|
lt_api_url=string(default="https://translate.nvda.es")
|
||||||
|
lt_api_key=string(default="")
|
||||||
|
deepl_api_key = string(default="")
|
||||||
54
src/blueski.defaults
Normal file
54
src/blueski.defaults
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
[blueski]
|
||||||
|
handle = string(default="")
|
||||||
|
app_password = string(default="")
|
||||||
|
did = string(default="")
|
||||||
|
session_string = string(default="")
|
||||||
|
user_name = string(default="")
|
||||||
|
|
||||||
|
[general]
|
||||||
|
relative_times = boolean(default=True)
|
||||||
|
max_posts_per_call = integer(default=40)
|
||||||
|
reverse_timelines = boolean(default=False)
|
||||||
|
persist_size = integer(default=0)
|
||||||
|
load_cache_in_memory = boolean(default=True)
|
||||||
|
show_screen_names = boolean(default=False)
|
||||||
|
hide_emojis = boolean(default=False)
|
||||||
|
buffer_order = list(default=list('home', 'notifications'))
|
||||||
|
disable_streaming = boolean(default=True)
|
||||||
|
|
||||||
|
[sound]
|
||||||
|
volume = float(default=1.0)
|
||||||
|
input_device = string(default="Default")
|
||||||
|
output_device = string(default="Default")
|
||||||
|
session_mute = boolean(default=False)
|
||||||
|
current_soundpack = string(default="FreakyBlue")
|
||||||
|
indicate_audio = boolean(default=True)
|
||||||
|
indicate_img = boolean(default=True)
|
||||||
|
|
||||||
|
[other_buffers]
|
||||||
|
timelines = list(default=list())
|
||||||
|
followers_timelines = list(default=list())
|
||||||
|
following_timelines = list(default=list())
|
||||||
|
searches = list(default=list())
|
||||||
|
muted_buffers = list(default=list())
|
||||||
|
autoread_buffers = list(default=list(notifications))
|
||||||
|
|
||||||
|
[mysc]
|
||||||
|
spelling_language = string(default="")
|
||||||
|
save_followers_in_autocompletion_db = boolean(default=False)
|
||||||
|
save_friends_in_autocompletion_db = boolean(default=False)
|
||||||
|
ocr_language = string(default="")
|
||||||
|
|
||||||
|
[reporting]
|
||||||
|
braille_reporting = boolean(default=True)
|
||||||
|
speech_reporting = boolean(default=True)
|
||||||
|
|
||||||
|
[templates]
|
||||||
|
post = string(default="$display_name, $reply_to$safe_text $date.")
|
||||||
|
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||||
|
notification = string(default="$display_name $text, $date")
|
||||||
|
|
||||||
|
[filters]
|
||||||
|
|
||||||
|
[user-aliases]
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# -*- coding: cp1252 -*-
|
# -*- coding: cp1252 -*-
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import config_utils
|
import config_utils
|
||||||
import paths
|
import paths
|
||||||
import logging
|
import logging
|
||||||
@@ -21,6 +22,9 @@ def setup ():
|
|||||||
log.debug("Loading keymap...")
|
log.debug("Loading keymap...")
|
||||||
global keymap
|
global keymap
|
||||||
if float(platform.version()[:2]) >= 10 and app["app-settings"]["load_keymap"] == "default.keymap":
|
if float(platform.version()[:2]) >= 10 and app["app-settings"]["load_keymap"] == "default.keymap":
|
||||||
|
if sys.getwindowsversion().build > 22000:
|
||||||
|
app["app-settings"]["load_keymap"] = "Windows11.keymap"
|
||||||
|
else:
|
||||||
app["app-settings"]["load_keymap"] = "Windows 10.keymap"
|
app["app-settings"]["load_keymap"] = "Windows 10.keymap"
|
||||||
app.write()
|
app.write()
|
||||||
global changed_keymap
|
global changed_keymap
|
||||||
|
|||||||
3
src/controller/blueski/__init__.py
Normal file
3
src/controller/blueski/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .handler import Handler
|
||||||
|
|
||||||
|
__all__ = ["Handler"]
|
||||||
973
src/controller/blueski/handler.py
Normal file
973
src/controller/blueski/handler.py
Normal file
@@ -0,0 +1,973 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import wx
|
||||||
|
import asyncio
|
||||||
|
import output
|
||||||
|
from mysc.thread_utils import call_threaded
|
||||||
|
import widgetUtils
|
||||||
|
from extra.autocompletionUsers import completion
|
||||||
|
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||||
|
from typing import Any
|
||||||
|
import languageHandler # Ensure _() injection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler:
|
||||||
|
"""Handler for Bluesky integration: creates minimal buffers."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.menus = dict(
|
||||||
|
# Application menu
|
||||||
|
updateProfile="HIDE",
|
||||||
|
menuitem_search=_("&Search"),
|
||||||
|
lists="HIDE",
|
||||||
|
manageAliases="HIDE",
|
||||||
|
# Item menu
|
||||||
|
compose=_("&Post"),
|
||||||
|
reply=_("Re&ply"),
|
||||||
|
share=_("&Boost"),
|
||||||
|
fav=_("&Add to favorites"),
|
||||||
|
unfav="HIDE",
|
||||||
|
view=_("&Show post"),
|
||||||
|
view_conversation=_("View conversa&tion"),
|
||||||
|
ocr=_("&OCR"),
|
||||||
|
delete=_("&Delete"),
|
||||||
|
# User menu
|
||||||
|
follow=_("&Actions..."),
|
||||||
|
timeline=_("&View timeline..."),
|
||||||
|
dm=_("Direct me&ssage"),
|
||||||
|
addAlias="HIDE",
|
||||||
|
addToList="HIDE",
|
||||||
|
removeFromList="HIDE",
|
||||||
|
details=_("S&how user profile"),
|
||||||
|
favs="HIDE",
|
||||||
|
# Buffer menu
|
||||||
|
community_timeline="HIDE",
|
||||||
|
filter="HIDE",
|
||||||
|
manage_filters="HIDE",
|
||||||
|
)
|
||||||
|
self.item_menu = _("&Post")
|
||||||
|
|
||||||
|
def create_buffers(self, session, createAccounts=True, controller=None):
|
||||||
|
name = session.get_name()
|
||||||
|
if createAccounts:
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage("core.create_account", name=name, session_id=session.session_id, logged=session.logged)
|
||||||
|
|
||||||
|
if not session.logged:
|
||||||
|
logger.debug(f"Session {session.session_id} is not logged in, skipping timeline buffer creation.")
|
||||||
|
return
|
||||||
|
if name not in controller.accounts:
|
||||||
|
controller.accounts.append(name)
|
||||||
|
|
||||||
|
root_position = controller.view.search(name, name)
|
||||||
|
from pubsub import pub
|
||||||
|
# Home (Following-only timeline - reverse-chronological)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="following_timeline",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Home"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="following_timeline", session=session, sound="tweet_received.ogg")
|
||||||
|
)
|
||||||
|
# Discover timeline
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="home_timeline",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Discover"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="home_timeline", session=session, sound="tweet_received.ogg")
|
||||||
|
)
|
||||||
|
# Mentions (replies, mentions, quotes)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="MentionsBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Mentions"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="mentions", session=session, sound="mention_received.ogg")
|
||||||
|
)
|
||||||
|
# Chats
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="ConversationListBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Chats"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="direct_messages", session=session, sound="dm_received.ogg")
|
||||||
|
)
|
||||||
|
# Notifications
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="notifications",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Notifications"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="notifications", session=session, sound="new_event.ogg")
|
||||||
|
)
|
||||||
|
# Sent posts
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="SentBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Sent"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="sent", session=session, sound="tweet_received.ogg")
|
||||||
|
)
|
||||||
|
# Likes
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="likes",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Likes"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="likes", session=session, sound="favourite.ogg")
|
||||||
|
)
|
||||||
|
# Followers
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="FollowersBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Followers"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="followers", session=session, sound="update_followers.ogg")
|
||||||
|
)
|
||||||
|
# Followings (Users you follow)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="FollowingBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Following"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="following", session=session, sound="update_followers.ogg")
|
||||||
|
)
|
||||||
|
# Blocks
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="BlocksBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=_("Blocked Users"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="blocked", session=session)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timelines container
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="EmptyBuffer",
|
||||||
|
session_type="base",
|
||||||
|
buffer_title=_("Timelines"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="timelines", account=name)
|
||||||
|
)
|
||||||
|
timelines_position = controller.view.search("timelines", name)
|
||||||
|
|
||||||
|
# Searches container (Bluesky supports search buffers)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="EmptyBuffer",
|
||||||
|
session_type="base",
|
||||||
|
buffer_title=_("Searches"),
|
||||||
|
parent_tab=root_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name="searches", account=name)
|
||||||
|
)
|
||||||
|
searches_position = controller.view.search("searches", name)
|
||||||
|
|
||||||
|
# Saved searches
|
||||||
|
try:
|
||||||
|
searches = session.settings["other_buffers"].get("searches")
|
||||||
|
if searches is None:
|
||||||
|
searches = []
|
||||||
|
if isinstance(searches, str):
|
||||||
|
searches = [s for s in searches.split(",") if s]
|
||||||
|
for query in searches:
|
||||||
|
buffer_name = f"search_{query[:20]}"
|
||||||
|
title = _("Search: {query}").format(query=query)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="SearchBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=searches_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=buffer_name, session=session, query=query, sound="search_updated.ogg")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore Bluesky search buffers: %s", e)
|
||||||
|
|
||||||
|
# Saved user timelines
|
||||||
|
try:
|
||||||
|
timelines = session.settings["other_buffers"].get("timelines")
|
||||||
|
if timelines is None:
|
||||||
|
timelines = []
|
||||||
|
if isinstance(timelines, str):
|
||||||
|
timelines = [t for t in timelines.split(",") if t]
|
||||||
|
for actor in timelines:
|
||||||
|
handle = actor
|
||||||
|
try:
|
||||||
|
if isinstance(actor, str) and actor.startswith("did:"):
|
||||||
|
profile = session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
handle = g(profile, "handle") or actor
|
||||||
|
except Exception:
|
||||||
|
handle = actor
|
||||||
|
title = _("Timeline for {user}").format(user=handle)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="UserTimeline",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=timelines_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=f"{handle}-timeline", session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore Bluesky timeline buffers: %s", e)
|
||||||
|
|
||||||
|
# Saved followers/following timelines
|
||||||
|
try:
|
||||||
|
followers = session.settings["other_buffers"].get("followers_timelines")
|
||||||
|
if followers is None:
|
||||||
|
followers = []
|
||||||
|
if isinstance(followers, str):
|
||||||
|
followers = [t for t in followers.split(",") if t]
|
||||||
|
for actor in followers:
|
||||||
|
handle = actor
|
||||||
|
try:
|
||||||
|
if isinstance(actor, str) and actor.startswith("did:"):
|
||||||
|
profile = session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
handle = g(profile, "handle") or actor
|
||||||
|
except Exception:
|
||||||
|
handle = actor
|
||||||
|
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||||
|
own_handle = session.db.get("user_name")
|
||||||
|
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||||
|
continue
|
||||||
|
title = _("Followers for {user}").format(user=handle)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="FollowersBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=timelines_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=f"{handle}-followers", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore Bluesky followers buffers: %s", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
following = session.settings["other_buffers"].get("following_timelines")
|
||||||
|
if following is None:
|
||||||
|
following = []
|
||||||
|
if isinstance(following, str):
|
||||||
|
following = [t for t in following.split(",") if t]
|
||||||
|
for actor in following:
|
||||||
|
handle = actor
|
||||||
|
try:
|
||||||
|
if isinstance(actor, str) and actor.startswith("did:"):
|
||||||
|
profile = session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
handle = g(profile, "handle") or actor
|
||||||
|
except Exception:
|
||||||
|
handle = actor
|
||||||
|
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||||
|
own_handle = session.db.get("user_name")
|
||||||
|
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||||
|
continue
|
||||||
|
title = _("Following for {user}").format(user=handle)
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="FollowingBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=timelines_position,
|
||||||
|
start=False,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=f"{handle}-following", session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to restore Bluesky following buffers: %s", e)
|
||||||
|
|
||||||
|
# Start the background poller for real-time-like updates
|
||||||
|
try:
|
||||||
|
session.start_streaming()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to start Bluesky streaming for session %s: %s", name, e)
|
||||||
|
|
||||||
|
def start_buffer(self, controller, buffer):
|
||||||
|
"""Start a newly created Bluesky buffer."""
|
||||||
|
try:
|
||||||
|
if hasattr(buffer, "start_stream"):
|
||||||
|
buffer.start_stream(mandatory=True, play_sound=False)
|
||||||
|
# Enable periodic auto-refresh to simulate real-time updates
|
||||||
|
if hasattr(buffer, "enable_auto_refresh"):
|
||||||
|
buffer.enable_auto_refresh()
|
||||||
|
finally:
|
||||||
|
# Ensure we won't try to start it again
|
||||||
|
try:
|
||||||
|
buffer.needs_init = False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def account_settings(self, buffer, controller):
|
||||||
|
"""Open a minimal account settings dialog for Bluesky."""
|
||||||
|
try:
|
||||||
|
current_mode = None
|
||||||
|
try:
|
||||||
|
current_mode = buffer.session.settings["general"].get("boost_mode")
|
||||||
|
except Exception:
|
||||||
|
current_mode = None
|
||||||
|
ask_default = True if current_mode in (None, "ask") else False
|
||||||
|
|
||||||
|
from wxUI.dialogs.blueski.configuration import AccountSettingsDialog
|
||||||
|
from .templateEditor import EditTemplate
|
||||||
|
dlg = AccountSettingsDialog(controller.view, ask_before_boost=ask_default)
|
||||||
|
try:
|
||||||
|
if buffer.session.settings.get("templates") is None:
|
||||||
|
buffer.session.settings["templates"] = {}
|
||||||
|
templates_cfg = buffer.session.settings.get("templates", {})
|
||||||
|
template_state = {
|
||||||
|
"post": templates_cfg.get("post", "$display_name, $reply_to$safe_text $date."),
|
||||||
|
"person": templates_cfg.get("person", "$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at."),
|
||||||
|
"notification": templates_cfg.get("notification", "$display_name $text, $date"),
|
||||||
|
}
|
||||||
|
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||||
|
|
||||||
|
def edit_post_template(*args, **kwargs):
|
||||||
|
control = EditTemplate(template=template_state["post"], type="post")
|
||||||
|
result = control.run_dialog()
|
||||||
|
if result:
|
||||||
|
buffer.session.settings["templates"]["post"] = result
|
||||||
|
buffer.session.settings.write()
|
||||||
|
template_state["post"] = result
|
||||||
|
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||||
|
|
||||||
|
def edit_person_template(*args, **kwargs):
|
||||||
|
control = EditTemplate(template=template_state["person"], type="person")
|
||||||
|
result = control.run_dialog()
|
||||||
|
if result:
|
||||||
|
buffer.session.settings["templates"]["person"] = result
|
||||||
|
buffer.session.settings.write()
|
||||||
|
template_state["person"] = result
|
||||||
|
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||||
|
|
||||||
|
def edit_notification_template(*args, **kwargs):
|
||||||
|
control = EditTemplate(template=template_state["notification"], type="notification")
|
||||||
|
result = control.run_dialog()
|
||||||
|
if result:
|
||||||
|
buffer.session.settings["templates"]["notification"] = result
|
||||||
|
buffer.session.settings.write()
|
||||||
|
template_state["notification"] = result
|
||||||
|
dlg.set_template_labels(template_state["post"], template_state["person"], template_state["notification"])
|
||||||
|
|
||||||
|
widgetUtils.connect_event(dlg.template_post, widgetUtils.BUTTON_PRESSED, edit_post_template)
|
||||||
|
widgetUtils.connect_event(dlg.template_person, widgetUtils.BUTTON_PRESSED, edit_person_template)
|
||||||
|
widgetUtils.connect_event(dlg.template_notification, widgetUtils.BUTTON_PRESSED, edit_notification_template)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to init Bluesky templates editor: %s", e)
|
||||||
|
resp = dlg.ShowModal()
|
||||||
|
if resp == wx.ID_OK:
|
||||||
|
vals = dlg.get_values()
|
||||||
|
boost_mode = "ask" if vals.get("ask_before_boost") else "direct"
|
||||||
|
try:
|
||||||
|
buffer.session.settings["general"]["boost_mode"] = boost_mode
|
||||||
|
buffer.session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to persist Bluesky boost_mode setting: %s", e)
|
||||||
|
dlg.Destroy()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error opening Bluesky account settings dialog: %s", e)
|
||||||
|
|
||||||
|
def user_details(self, buffer):
|
||||||
|
"""Show user profile dialog for the selected user/post."""
|
||||||
|
session = getattr(buffer, "session", None)
|
||||||
|
if not session:
|
||||||
|
output.speak(_("No active session to view user details."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
item = buffer.get_item() if hasattr(buffer, "get_item") else None
|
||||||
|
if not item:
|
||||||
|
output.speak(_("No user selected or identified to view details."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
user_ident = None
|
||||||
|
|
||||||
|
# If we're in a user list, the item itself is the user profile dict/model.
|
||||||
|
if g(item, "did") or g(item, "handle"):
|
||||||
|
user_ident = g(item, "did") or g(item, "handle")
|
||||||
|
else:
|
||||||
|
author = g(item, "author")
|
||||||
|
if not author:
|
||||||
|
post = g(item, "post") or g(item, "record")
|
||||||
|
author = g(post, "author") if post else None
|
||||||
|
if author:
|
||||||
|
user_ident = g(author, "did") or g(author, "handle")
|
||||||
|
|
||||||
|
if not user_ident:
|
||||||
|
output.speak(_("No user selected or identified to view details."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
parent = getattr(buffer, "buffer", None) or wx.GetApp().GetTopWindow()
|
||||||
|
dialog = ShowUserProfileDialog(parent, session, user_ident)
|
||||||
|
dialog.ShowModal()
|
||||||
|
dialog.Destroy()
|
||||||
|
|
||||||
|
async def handle_action(self, action_name: str, user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
logger.debug("handle_action stub: %s %s %s", action_name, user_id, payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_message_command(self, command: str, user_id: str, message_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
logger.debug("handle_message_command stub: %s %s %s %s", command, user_id, message_id, payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_user_command(self, command: str, user_id: str, target_user_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
logger.debug("handle_user_command stub: %s %s %s %s", command, user_id, target_user_id, payload)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def add_to_favourites(self, buffer):
|
||||||
|
"""Standard action for Alt+Win+F"""
|
||||||
|
if hasattr(buffer, "add_to_favorites"):
|
||||||
|
buffer.add_to_favorites()
|
||||||
|
elif hasattr(buffer, "on_like"):
|
||||||
|
# Fallback
|
||||||
|
buffer.on_like(None)
|
||||||
|
|
||||||
|
def remove_from_favourites(self, buffer):
|
||||||
|
"""Standard action for Alt+Shift+Win+F"""
|
||||||
|
if hasattr(buffer, "remove_from_favorites"):
|
||||||
|
buffer.remove_from_favorites()
|
||||||
|
elif hasattr(buffer, "on_like"):
|
||||||
|
buffer.on_like(None)
|
||||||
|
|
||||||
|
def follow(self, buffer):
|
||||||
|
"""Standard action for Ctrl+Win+S - Opens user actions dialog"""
|
||||||
|
if not hasattr(buffer, "get_item"):
|
||||||
|
return
|
||||||
|
session = getattr(buffer, "session", None)
|
||||||
|
if not session:
|
||||||
|
output.speak(_("No active session."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
item = buffer.get_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
users = []
|
||||||
|
buffer_type = getattr(buffer, "type", "")
|
||||||
|
|
||||||
|
if buffer_type in ("user", "post_user_list"):
|
||||||
|
# User buffer - item is a user object
|
||||||
|
handle = g(item, "handle")
|
||||||
|
if handle:
|
||||||
|
users = [handle]
|
||||||
|
elif buffer_type == "notifications":
|
||||||
|
# Notification buffer
|
||||||
|
author = g(item, "author")
|
||||||
|
if author:
|
||||||
|
handle = g(author, "handle")
|
||||||
|
if handle:
|
||||||
|
users.append(handle)
|
||||||
|
# Also check for post author in the notification subject
|
||||||
|
record = g(item, "record")
|
||||||
|
if record:
|
||||||
|
subject = g(record, "subject")
|
||||||
|
if subject:
|
||||||
|
subject_author = g(subject, "author")
|
||||||
|
if subject_author:
|
||||||
|
subject_handle = g(subject_author, "handle")
|
||||||
|
if subject_handle and subject_handle not in users:
|
||||||
|
users.append(subject_handle)
|
||||||
|
else:
|
||||||
|
# Post buffer - extract author and mentioned users
|
||||||
|
# Get the actual post (could be nested in "post" key)
|
||||||
|
actual_post = g(item, "post", item)
|
||||||
|
record = g(actual_post, "record") or {}
|
||||||
|
|
||||||
|
# Extract mentions from facets
|
||||||
|
facets = g(record, "facets") or []
|
||||||
|
for facet in facets:
|
||||||
|
features = g(facet, "features") or []
|
||||||
|
for feature in features:
|
||||||
|
ftype = g(feature, "$type") or g(feature, "py_type") or ""
|
||||||
|
if "mention" in ftype.lower():
|
||||||
|
mention_did = g(feature, "did")
|
||||||
|
# We'd need to resolve DID to handle, but for simplicity just skip
|
||||||
|
# The main author will be added below
|
||||||
|
|
||||||
|
# Get the post author
|
||||||
|
author = g(actual_post, "author") or g(item, "author")
|
||||||
|
if author:
|
||||||
|
handle = g(author, "handle")
|
||||||
|
if handle and handle not in users:
|
||||||
|
users.insert(0, handle)
|
||||||
|
|
||||||
|
# Ensure we have at least the author if no users found
|
||||||
|
if not users:
|
||||||
|
author = g(item, "author") or g(g(item, "post"), "author")
|
||||||
|
if author:
|
||||||
|
handle = g(author, "handle")
|
||||||
|
if handle:
|
||||||
|
users = [handle]
|
||||||
|
|
||||||
|
from controller.blueski import userActions as user_actions_controller
|
||||||
|
user_actions_controller.userActions(session, users)
|
||||||
|
|
||||||
|
def open_conversation(self, controller, buffer):
|
||||||
|
"""Standard action for Control+Win+C"""
|
||||||
|
# If this is a chat conversation list, open the selected chat
|
||||||
|
if buffer.type == "chat" and hasattr(buffer, "view_chat"):
|
||||||
|
buffer.view_chat()
|
||||||
|
return
|
||||||
|
item = buffer.get_item()
|
||||||
|
if not item:
|
||||||
|
return
|
||||||
|
|
||||||
|
uri = None
|
||||||
|
if hasattr(buffer, "get_selected_item_id"):
|
||||||
|
uri = buffer.get_selected_item_id()
|
||||||
|
if not uri:
|
||||||
|
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
|
||||||
|
if not uri: return
|
||||||
|
|
||||||
|
# Buffer Title
|
||||||
|
handle = None
|
||||||
|
display_name = None
|
||||||
|
if hasattr(buffer, "get_selected_item_author_details"):
|
||||||
|
details = buffer.get_selected_item_author_details()
|
||||||
|
if details:
|
||||||
|
handle = details.get("handle")
|
||||||
|
if not handle:
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
author = g(item, "author") or g(g(item, "post"), "author")
|
||||||
|
if author:
|
||||||
|
handle = g(author, "handle")
|
||||||
|
display_name = g(author, "displayName") or g(author, "display_name")
|
||||||
|
label = handle or display_name or _("Unknown")
|
||||||
|
title = _("Conversation with {0}").format(label)
|
||||||
|
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="conversation",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=controller.view.search(buffer.session.get_name(), buffer.session.get_name()) if hasattr(buffer.session, "get_name") else None,
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=title, session=buffer.session, uri=uri, sound="search_updated.ogg")
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_timeline(self, controller, buffer, default="posts"):
|
||||||
|
if not hasattr(buffer, "get_item"):
|
||||||
|
return
|
||||||
|
item = buffer.get_item()
|
||||||
|
if not item:
|
||||||
|
output.speak(_("No user selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
users = []
|
||||||
|
handle = None
|
||||||
|
if hasattr(buffer, "get_selected_item_author_details"):
|
||||||
|
details = buffer.get_selected_item_author_details()
|
||||||
|
if details:
|
||||||
|
handle = details.get("handle") or details.get("did")
|
||||||
|
if not handle:
|
||||||
|
if g(item, "handle") or g(item, "did"):
|
||||||
|
handle = g(item, "handle") or g(item, "did")
|
||||||
|
else:
|
||||||
|
author = g(item, "author") or g(g(item, "post"), "author")
|
||||||
|
if author:
|
||||||
|
handle = g(author, "handle") or g(author, "did")
|
||||||
|
|
||||||
|
if not handle:
|
||||||
|
output.speak(_("No user selected."), True)
|
||||||
|
return
|
||||||
|
users.append(handle)
|
||||||
|
|
||||||
|
# Add mentioned users if available (facets)
|
||||||
|
record = g(g(item, "post"), "record") or g(item, "record")
|
||||||
|
facets = g(record, "facets", []) if record else []
|
||||||
|
handle_cache = {}
|
||||||
|
|
||||||
|
def resolve_handle(did):
|
||||||
|
if did in handle_cache:
|
||||||
|
return handle_cache[did]
|
||||||
|
try:
|
||||||
|
profile = buffer.session.get_profile(did)
|
||||||
|
if profile:
|
||||||
|
h = g(profile, "handle")
|
||||||
|
if h:
|
||||||
|
handle_cache[did] = h
|
||||||
|
return h
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
self_did = buffer.session.db.get("user_id")
|
||||||
|
for facet in facets or []:
|
||||||
|
features = g(facet, "features", []) or []
|
||||||
|
for feat in features:
|
||||||
|
ftype = g(feat, "$type") or g(feat, "py_type") or ""
|
||||||
|
if "facet#mention" in ftype:
|
||||||
|
did = g(feat, "did")
|
||||||
|
if not did or did == self_did:
|
||||||
|
continue
|
||||||
|
h = resolve_handle(did)
|
||||||
|
if h and h not in users:
|
||||||
|
users.append(h)
|
||||||
|
|
||||||
|
from wxUI.dialogs.mastodon import userTimeline as userTimelineDialog
|
||||||
|
dlg = userTimelineDialog.UserTimeline(users=users, default=default)
|
||||||
|
try:
|
||||||
|
widgetUtils.connect_event(
|
||||||
|
dlg.autocompletion,
|
||||||
|
widgetUtils.BUTTON_PRESSED,
|
||||||
|
lambda *args, **kwargs: completion.autocompletionUsers(dlg, buffer.session.session_id).show_menu("free"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if hasattr(dlg, "autocompletion"):
|
||||||
|
dlg.autocompletion.Enable(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if dlg.ShowModal() != wx.ID_OK:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
action = dlg.get_action()
|
||||||
|
user = dlg.get_user().strip() or handle
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
if user.startswith("@"):
|
||||||
|
user = user[1:]
|
||||||
|
try:
|
||||||
|
profile = buffer.session.get_profile(user)
|
||||||
|
if profile is None:
|
||||||
|
output.speak(_("User not found."), True)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user_payload = {"handle": user}
|
||||||
|
if action == "posts":
|
||||||
|
result = self.open_user_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||||
|
elif action == "followers":
|
||||||
|
result = self.open_followers_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||||
|
elif action == "following":
|
||||||
|
result = self.open_following_timeline(main_controller=controller, session=buffer.session, user_payload=user_payload)
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
call_threaded(asyncio.run, result)
|
||||||
|
|
||||||
|
def open_followers_timeline(self, main_controller, session, user_payload=None):
|
||||||
|
actor, handle = self._resolve_actor(session, user_payload)
|
||||||
|
if not actor:
|
||||||
|
output.speak(_("No user selected."), True)
|
||||||
|
return
|
||||||
|
self._open_user_list(main_controller, session, actor, handle, list_type="followers")
|
||||||
|
|
||||||
|
def open_following_timeline(self, main_controller, session, user_payload=None):
|
||||||
|
actor, handle = self._resolve_actor(session, user_payload)
|
||||||
|
if not actor:
|
||||||
|
output.speak(_("No user selected."), True)
|
||||||
|
return
|
||||||
|
self._open_user_list(main_controller, session, actor, handle, list_type="following")
|
||||||
|
|
||||||
|
def open_user_timeline(self, main_controller, session, user_payload=None):
|
||||||
|
"""Open posts timeline for a user (Alt+Win+I)."""
|
||||||
|
actor, handle = self._resolve_actor(session, user_payload)
|
||||||
|
if not actor:
|
||||||
|
output.speak(_("No user selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
actor, handle = self._resolve_actor(session, {"did": actor, "handle": handle})
|
||||||
|
if not handle:
|
||||||
|
handle = actor
|
||||||
|
|
||||||
|
account_name = session.get_name()
|
||||||
|
list_name = f"{handle}-timeline"
|
||||||
|
if main_controller.search_buffer(list_name, account_name):
|
||||||
|
index = main_controller.view.search(list_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
main_controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
|
||||||
|
title = _("Timeline for {user}").format(user=handle)
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="UserTimeline",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=main_controller.view.search("timelines", account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="tweet_timeline.ogg")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
timelines = session.settings["other_buffers"].get("timelines")
|
||||||
|
if timelines is None:
|
||||||
|
timelines = []
|
||||||
|
if isinstance(timelines, str):
|
||||||
|
timelines = [t for t in timelines.split(",") if t]
|
||||||
|
key = actor or handle
|
||||||
|
if key in timelines:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
commonMessageDialogs.timeline_exist()
|
||||||
|
return
|
||||||
|
if key:
|
||||||
|
timelines.append(key)
|
||||||
|
session.settings["other_buffers"]["timelines"] = timelines
|
||||||
|
session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to persist Bluesky timeline buffer: %s", e)
|
||||||
|
|
||||||
|
def _resolve_actor(self, session, user_payload):
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
actor = None
|
||||||
|
handle = None
|
||||||
|
if user_payload:
|
||||||
|
actor = g(user_payload, "did") or g(user_payload, "handle")
|
||||||
|
handle = g(user_payload, "handle") or g(user_payload, "did")
|
||||||
|
if isinstance(actor, str):
|
||||||
|
actor = actor.strip()
|
||||||
|
if actor.startswith("@"):
|
||||||
|
actor = actor[1:]
|
||||||
|
if isinstance(handle, str):
|
||||||
|
handle = handle.strip()
|
||||||
|
if handle.startswith("@"):
|
||||||
|
handle = handle[1:]
|
||||||
|
# Resolve handle -> DID when possible, and keep handle for titles
|
||||||
|
try:
|
||||||
|
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||||
|
profile = session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
did = g(profile, "did")
|
||||||
|
if did:
|
||||||
|
actor = did
|
||||||
|
if not handle:
|
||||||
|
handle = g(profile, "handle")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not actor:
|
||||||
|
actor = session.db.get("user_id") or session.db.get("user_name")
|
||||||
|
handle = session.db.get("user_name") or actor
|
||||||
|
|
||||||
|
if not handle and isinstance(actor, str):
|
||||||
|
try:
|
||||||
|
if actor.startswith("did:"):
|
||||||
|
profile = session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
handle = g(profile, "handle")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return actor, handle
|
||||||
|
|
||||||
|
def _open_user_list(self, main_controller, session, actor, handle, list_type):
|
||||||
|
account_name = session.get_name()
|
||||||
|
if not handle:
|
||||||
|
handle = actor
|
||||||
|
own_actor = session.db.get("user_id") or session.db.get("user_name")
|
||||||
|
own_handle = session.db.get("user_name")
|
||||||
|
if actor == own_actor or (own_handle and actor == own_handle) or (handle and own_handle and handle == own_handle):
|
||||||
|
name = "followers" if list_type == "followers" else "following"
|
||||||
|
try:
|
||||||
|
stored = session.settings["other_buffers"].get("followers_timelines" if list_type == "followers" else "following_timelines") or []
|
||||||
|
if isinstance(stored, str):
|
||||||
|
stored = [t for t in stored.split(",") if t]
|
||||||
|
if actor in stored:
|
||||||
|
stored.remove(actor)
|
||||||
|
session.settings["other_buffers"]["followers_timelines" if list_type == "followers" else "following_timelines"] = stored
|
||||||
|
session.settings.write()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
index = main_controller.view.search(name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
main_controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
list_name = f"{handle}-{list_type}"
|
||||||
|
if main_controller.search_buffer(list_name, account_name):
|
||||||
|
index = main_controller.view.search(list_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
main_controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
|
||||||
|
settings_key = "followers_timelines" if list_type == "followers" else "following_timelines"
|
||||||
|
try:
|
||||||
|
stored = session.settings["other_buffers"].get(settings_key)
|
||||||
|
if stored is None:
|
||||||
|
stored = []
|
||||||
|
if isinstance(stored, str):
|
||||||
|
stored = [t for t in stored.split(",") if t]
|
||||||
|
key = actor or handle
|
||||||
|
if key in stored:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
commonMessageDialogs.timeline_exist()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
stored = None
|
||||||
|
|
||||||
|
title = _("Followers for {user}").format(user=handle) if list_type == "followers" else _("Following for {user}").format(user=handle)
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="FollowersBuffer" if list_type == "followers" else "FollowingBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=main_controller.view.search("timelines", account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=main_controller.view.nb, name=list_name, session=session, actor=actor, handle=handle, sound="new_event.ogg")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if stored is None:
|
||||||
|
stored = session.settings["other_buffers"].get(settings_key) or []
|
||||||
|
if isinstance(stored, str):
|
||||||
|
stored = [t for t in stored.split(",") if t]
|
||||||
|
key = actor or handle
|
||||||
|
if key:
|
||||||
|
stored.append(key)
|
||||||
|
session.settings["other_buffers"][settings_key] = stored
|
||||||
|
session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to persist Bluesky %s buffer: %s", list_type, e)
|
||||||
|
|
||||||
|
def delete(self, buffer, controller):
|
||||||
|
"""Standard action for delete key / menu item"""
|
||||||
|
item = buffer.get_item()
|
||||||
|
if not item: return
|
||||||
|
|
||||||
|
uri = getattr(item, "uri", None) or (item.get("post", {}).get("uri") if isinstance(item, dict) else None)
|
||||||
|
if not uri: return
|
||||||
|
|
||||||
|
import wx
|
||||||
|
if wx.MessageBox(_("Are you sure you want to delete this post?"), _("Delete post"), wx.YES_NO | wx.ICON_QUESTION) == wx.YES:
|
||||||
|
if buffer.session.delete_post(uri):
|
||||||
|
import output
|
||||||
|
output.speak(_("Post deleted."))
|
||||||
|
# Refresh buffer
|
||||||
|
if hasattr(buffer, "start_stream"):
|
||||||
|
buffer.start_stream(mandatory=True, play_sound=False)
|
||||||
|
else:
|
||||||
|
import output
|
||||||
|
output.speak(_("Failed to delete post."))
|
||||||
|
|
||||||
|
def search(self, controller, session, value=""):
|
||||||
|
"""Open search dialog and create search buffer for results."""
|
||||||
|
dlg = wx.TextEntryDialog(
|
||||||
|
controller.view,
|
||||||
|
_("Enter search term:"),
|
||||||
|
_("Search Bluesky"),
|
||||||
|
value
|
||||||
|
)
|
||||||
|
if dlg.ShowModal() != wx.ID_OK:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
query = dlg.GetValue().strip()
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create unique buffer name for this search
|
||||||
|
buffer_name = f"search_{query[:20]}"
|
||||||
|
account_name = session.get_name()
|
||||||
|
|
||||||
|
# Check if buffer already exists
|
||||||
|
existing = controller.search_buffer(buffer_name, account_name)
|
||||||
|
if existing:
|
||||||
|
# Navigate to existing buffer
|
||||||
|
index = controller.view.search(buffer_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
controller.view.change_buffer(index)
|
||||||
|
# Refresh search
|
||||||
|
existing.search_query = query
|
||||||
|
existing.start_stream(mandatory=True, play_sound=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create new search buffer
|
||||||
|
title = _("Search: {query}").format(query=query)
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="SearchBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=controller.view.search("searches", account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(
|
||||||
|
parent=controller.view.nb,
|
||||||
|
name=buffer_name,
|
||||||
|
session=session,
|
||||||
|
query=query,
|
||||||
|
sound="search_updated.ogg"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save search to settings for persistence
|
||||||
|
try:
|
||||||
|
searches = session.settings["other_buffers"].get("searches")
|
||||||
|
if searches is None:
|
||||||
|
searches = []
|
||||||
|
if isinstance(searches, str):
|
||||||
|
searches = [s for s in searches.split(",") if s]
|
||||||
|
if query not in searches:
|
||||||
|
searches.append(query)
|
||||||
|
session.settings["other_buffers"]["searches"] = searches
|
||||||
|
session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to save search to settings: %s", e)
|
||||||
433
src/controller/blueski/messages.py
Normal file
433
src/controller/blueski/messages.py
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import languageHandler
|
||||||
|
import output
|
||||||
|
import widgetUtils
|
||||||
|
from controller import messages as base_messages
|
||||||
|
from sessions.blueski import utils as bluesky_utils
|
||||||
|
from wxUI.dialogs.blueski import postDialogs
|
||||||
|
from extra.autocompletionUsers import completion
|
||||||
|
|
||||||
|
# Translation function is provided globally by TWBlue's language handler (_)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# This file would typically contain functions to generate complex message bodies or
|
||||||
|
# interactive components for Blueski, similar to how it might be done for Mastodon.
|
||||||
|
# Since Blueski's interactive features (beyond basic posts) are still evolving
|
||||||
|
# or client-dependent (like polls), this might be less complex initially.
|
||||||
|
|
||||||
|
# Example: If Blueski develops a standard for "cards" or interactive messages,
|
||||||
|
# functions to create those would go here. For now, we can imagine placeholders.
|
||||||
|
|
||||||
|
def format_welcome_message(session: Any) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generates a welcome message for a new Blueski session.
|
||||||
|
This is just a placeholder and example.
|
||||||
|
"""
|
||||||
|
# user_profile = session.util.get_own_profile_info() # Assuming this method exists and is async or cached
|
||||||
|
# handle = user_profile.get("handle", _("your Blueski account")) if user_profile else _("your Blueski account")
|
||||||
|
# Expect session to expose username via db/settings
|
||||||
|
handle = (getattr(session, "db", {}).get("user_name")
|
||||||
|
or getattr(getattr(session, "settings", {}), "get", lambda *_: {})("blueski").get("handle")
|
||||||
|
or _("your Bluesky account"))
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": _("Welcome to Approve for Blueski! Your account {handle} is connected.").format(handle=handle),
|
||||||
|
# "blocks": [ # If Blueski supports a block kit like Slack or Discord
|
||||||
|
# {
|
||||||
|
# "type": "section",
|
||||||
|
# "text": {
|
||||||
|
# "type": "mrkdwn", # Or Blueski's equivalent
|
||||||
|
# "text": _("Welcome to Approve for Blueski! Your account *{handle}* is connected.").format(handle=handle)
|
||||||
|
# }
|
||||||
|
# },
|
||||||
|
# {
|
||||||
|
# "type": "actions",
|
||||||
|
# "elements": [
|
||||||
|
# {
|
||||||
|
# "type": "button",
|
||||||
|
# "text": {"type": "plain_text", "text": _("Post your first Skeet")},
|
||||||
|
# "action_id": "blueski_compose_new_post" # Example action ID
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
}
|
||||||
|
|
||||||
|
def format_error_message(error_description: str, details: str | None = None) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generates a standardized error message.
|
||||||
|
"""
|
||||||
|
message = {"text": f":warning: Error: {error_description}"} # Basic text message
|
||||||
|
# if details:
|
||||||
|
# message["blocks"] = [
|
||||||
|
# {
|
||||||
|
# "type": "section",
|
||||||
|
# "text": {"type": "mrkdwn", "text": f":warning: *Error:* {error_description}\n{details}"}
|
||||||
|
# }
|
||||||
|
# ]
|
||||||
|
return message
|
||||||
|
|
||||||
|
# More functions could be added here as Blueski's capabilities become clearer
|
||||||
|
# or as specific formatting needs for Approve arise. For example:
|
||||||
|
# - Formatting a post for display with all its embeds and cards.
|
||||||
|
# - Generating help messages specific to Blueski features.
|
||||||
|
# - Creating interactive messages for polls (if supported via some convention).
|
||||||
|
|
||||||
|
# Example of adapting a function that might exist in mastodon_messages:
|
||||||
|
# def build_post_summary_message(session: BlueskiSession, post_uri: str, post_content: dict) -> dict[str, Any]:
|
||||||
|
# """
|
||||||
|
# Builds a summary message for an Blueski post.
|
||||||
|
# """
|
||||||
|
# author_handle = post_content.get("author", {}).get("handle", "Unknown user")
|
||||||
|
# text_preview = post_content.get("text", "")[:100] # First 100 chars of text
|
||||||
|
# # url = session.get_message_url(post_uri) # Assuming this method exists
|
||||||
|
# url = f"https://bsky.app/profile/{author_handle}/post/{post_uri.split('/')[-1]}" # Construct a URL
|
||||||
|
|
||||||
|
# return {
|
||||||
|
# "text": _("Post by {author_handle}: {text_preview}... ({url})").format(
|
||||||
|
# author_handle=author_handle, text_preview=text_preview, url=url
|
||||||
|
# ),
|
||||||
|
# # Potentially with "blocks" for richer formatting if the platform supports it
|
||||||
|
# }
|
||||||
|
|
||||||
|
logger.info("Blueski messages module loaded (placeholders).")
|
||||||
|
|
||||||
|
|
||||||
|
class post(base_messages.basicMessage):
|
||||||
|
# Bluesky character limit
|
||||||
|
MAX_CHARS = 300
|
||||||
|
|
||||||
|
def __init__(self, session: Any, title: str, caption: str, text: str = "", *args, **kwargs):
|
||||||
|
self.session = session
|
||||||
|
self.title = title
|
||||||
|
langs = session.supported_languages
|
||||||
|
display_langs = [l.name for l in langs]
|
||||||
|
self.message = postDialogs.Post(caption=caption, text=text, languages=display_langs, *args, **kwargs)
|
||||||
|
try:
|
||||||
|
self.message.SetTitle(title)
|
||||||
|
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Set default language
|
||||||
|
self.set_language(session.default_language)
|
||||||
|
# Connect events for text processing and buttons
|
||||||
|
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||||
|
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||||
|
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||||
|
widgetUtils.connect_event(self.message.translate, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||||
|
widgetUtils.connect_event(self.message.autocomplete_users, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||||
|
# Initial text processing to show character count
|
||||||
|
self.text_processor()
|
||||||
|
|
||||||
|
def set_language(self, language_code=None):
|
||||||
|
"""Set the language selection based on language code."""
|
||||||
|
if language_code is None:
|
||||||
|
language_code = languageHandler.curLang[:2]
|
||||||
|
for idx, lang in enumerate(self.session.supported_languages):
|
||||||
|
if lang.code == language_code:
|
||||||
|
self.message.language.SetSelection(idx)
|
||||||
|
return
|
||||||
|
# If not found, select first item (Not set)
|
||||||
|
self.message.language.SetSelection(0)
|
||||||
|
|
||||||
|
def get_language(self):
|
||||||
|
"""Get the selected language code."""
|
||||||
|
langs = self.session.supported_languages
|
||||||
|
idx = self.message.language.GetSelection()
|
||||||
|
if idx >= 0 and idx < len(langs):
|
||||||
|
return langs[idx].code
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_data(self):
|
||||||
|
text, files, cw_text, lang_index = self.message.get_payload()
|
||||||
|
langs = self.session.supported_languages
|
||||||
|
lang_code = None
|
||||||
|
if lang_index >= 0 and lang_index < len(langs):
|
||||||
|
lang_code = langs[lang_index].code
|
||||||
|
return text, files, cw_text, ([lang_code] if lang_code else [])
|
||||||
|
|
||||||
|
def text_processor(self, *args, **kwargs):
|
||||||
|
text = self.message.text.GetValue()
|
||||||
|
cw = self.message.spoiler.GetValue() if self.message.spoiler.IsEnabled() else ""
|
||||||
|
char_count = len(text) + len(cw)
|
||||||
|
self.message.SetTitle(_("%s - %s of %d characters") % (self.title, char_count, self.MAX_CHARS))
|
||||||
|
if char_count > self.MAX_CHARS:
|
||||||
|
self.session.sound.play("max_length.ogg")
|
||||||
|
|
||||||
|
def autocomplete_users(self, *args, **kwargs):
|
||||||
|
c = completion.autocompletionUsers(self.message, self.session.session_id)
|
||||||
|
c.show_menu()
|
||||||
|
|
||||||
|
|
||||||
|
def _g(obj: Any, key: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def has_post_data(item: Any) -> bool:
|
||||||
|
post = _g(item, "post")
|
||||||
|
record = _g(post, "record") if post is not None else None
|
||||||
|
if record is None:
|
||||||
|
record = _g(item, "record")
|
||||||
|
return record is not None or post is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_labels(obj: Any) -> list[dict[str, Any]]:
|
||||||
|
labels = _g(obj, "labels", None)
|
||||||
|
if labels is None:
|
||||||
|
return []
|
||||||
|
if isinstance(labels, dict):
|
||||||
|
labels = labels.get("values", [])
|
||||||
|
if isinstance(labels, list):
|
||||||
|
return labels
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cw_text(post: Any, record: Any) -> str:
|
||||||
|
labels = _extract_labels(post) + _extract_labels(record)
|
||||||
|
for label in labels:
|
||||||
|
val = _g(label, "val", "")
|
||||||
|
if val == "warn":
|
||||||
|
return _("Sensitive Content")
|
||||||
|
if isinstance(val, str) and val.startswith("warn:"):
|
||||||
|
return val.split("warn:", 1)[-1].strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_image_descriptions(post: Any, record: Any) -> str:
|
||||||
|
def _collect_images(embed: Any) -> list[Any]:
|
||||||
|
if not embed:
|
||||||
|
return []
|
||||||
|
etype = _g(embed, "$type") or _g(embed, "py_type") or ""
|
||||||
|
if "recordWithMedia" in etype:
|
||||||
|
media = _g(embed, "media")
|
||||||
|
mtype = _g(media, "$type") or _g(media, "py_type") or ""
|
||||||
|
if "images" in mtype:
|
||||||
|
return list(_g(media, "images", []) or [])
|
||||||
|
return []
|
||||||
|
if "images" in etype:
|
||||||
|
return list(_g(embed, "images", []) or [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
images = []
|
||||||
|
images.extend(_collect_images(_g(post, "embed")))
|
||||||
|
if not images:
|
||||||
|
images.extend(_collect_images(_g(record, "embed")))
|
||||||
|
|
||||||
|
descriptions = []
|
||||||
|
for idx, img in enumerate(images, start=1):
|
||||||
|
alt = _g(img, "alt", "") or ""
|
||||||
|
if alt:
|
||||||
|
descriptions.append(_("Image {index}: {alt}").format(index=idx, alt=alt))
|
||||||
|
return "\n".join(descriptions)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date(raw_date: str | None, offset_hours: int = 0) -> str:
|
||||||
|
if not raw_date:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
ts = arrow.get(raw_date)
|
||||||
|
if offset_hours:
|
||||||
|
ts = ts.shift(hours=offset_hours)
|
||||||
|
return ts.format(_("dddd, MMMM D, YYYY H:m"), locale=languageHandler.curLang[:2])
|
||||||
|
except Exception:
|
||||||
|
return str(raw_date)[:16].replace("T", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_post_view_data(session: Any, item: Any) -> dict[str, Any] | None:
|
||||||
|
post = _g(item, "post", item)
|
||||||
|
record = _g(post, "record") or _g(item, "record")
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
author = _g(post, "author") or _g(item, "author") or {}
|
||||||
|
handle = _g(author, "handle", "")
|
||||||
|
display_name = _g(author, "displayName") or _g(author, "display_name") or handle or _("Unknown")
|
||||||
|
if handle and display_name != handle:
|
||||||
|
author_label = f"{display_name} (@{handle})"
|
||||||
|
elif handle:
|
||||||
|
author_label = f"@{handle}"
|
||||||
|
else:
|
||||||
|
author_label = display_name
|
||||||
|
|
||||||
|
text = _g(record, "text", "") or ""
|
||||||
|
reply_to_handle = bluesky_utils.extract_reply_to_handle(item)
|
||||||
|
if reply_to_handle:
|
||||||
|
if text:
|
||||||
|
text = _("Replying to @{handle}: {text}").format(handle=reply_to_handle, text=text)
|
||||||
|
else:
|
||||||
|
text = _("Replying to @{handle}").format(handle=reply_to_handle)
|
||||||
|
|
||||||
|
quote_info = bluesky_utils.extract_quoted_post_info(item)
|
||||||
|
if quote_info:
|
||||||
|
if quote_info["kind"] == "not_found":
|
||||||
|
text += f" [{_('Quoted post not found')}]"
|
||||||
|
elif quote_info["kind"] == "blocked":
|
||||||
|
text += f" [{_('Quoted post blocked')}]"
|
||||||
|
elif quote_info["kind"] == "feed":
|
||||||
|
text += f" [{_('Quoting Feed')}: {quote_info.get('feed_name', 'Feed')}]"
|
||||||
|
else:
|
||||||
|
q_handle = quote_info.get("handle", "unknown")
|
||||||
|
q_text = quote_info.get("text", "")
|
||||||
|
if q_text:
|
||||||
|
text += " " + _("Quoting @{handle}: {text}").format(handle=q_handle, text=q_text)
|
||||||
|
else:
|
||||||
|
text += " " + _("Quoting @{handle}").format(handle=q_handle)
|
||||||
|
|
||||||
|
cw_text = _extract_cw_text(post, record)
|
||||||
|
if cw_text:
|
||||||
|
text = f"CW: {cw_text}\n\n{text}" if text else f"CW: {cw_text}"
|
||||||
|
|
||||||
|
created_at = _g(record, "createdAt") or _g(record, "created_at")
|
||||||
|
indexed_at = _g(post, "indexedAt") or _g(post, "indexed_at")
|
||||||
|
date = _format_date(created_at or indexed_at, offset_hours=_g(session.db, "utc_offset", 0))
|
||||||
|
|
||||||
|
reply_count = _g(post, "replyCount", 0) or 0
|
||||||
|
repost_count = _g(post, "repostCount", 0) or 0
|
||||||
|
like_count = _g(post, "likeCount", 0) or 0
|
||||||
|
|
||||||
|
uri = _g(post, "uri") or _g(item, "uri")
|
||||||
|
item_url = ""
|
||||||
|
if uri and handle:
|
||||||
|
rkey = uri.split("/")[-1]
|
||||||
|
item_url = f"https://bsky.app/profile/{handle}/post/{rkey}"
|
||||||
|
|
||||||
|
image_description = _extract_image_descriptions(post, record)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"author": author_label,
|
||||||
|
"text": text,
|
||||||
|
"date": date,
|
||||||
|
"replies": reply_count,
|
||||||
|
"reposts": repost_count,
|
||||||
|
"likes": like_count,
|
||||||
|
"source": _("Bluesky"),
|
||||||
|
"privacy": _("Public"),
|
||||||
|
"image_description": image_description,
|
||||||
|
"item_url": item_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class viewPost(base_messages.basicMessage):
|
||||||
|
def __init__(self, session: Any, item: Any, controller: Any = None):
|
||||||
|
self.session = session
|
||||||
|
self.controller = controller
|
||||||
|
data = _extract_post_view_data(session, item)
|
||||||
|
if not data:
|
||||||
|
output.speak(_("No post available to view."), True)
|
||||||
|
return
|
||||||
|
self.post_uri = _g(_g(item, "post", item), "uri") or _g(item, "uri")
|
||||||
|
title = _("Post from {}").format(data["author"])
|
||||||
|
self.message = postDialogs.viewPost(
|
||||||
|
text=data["text"],
|
||||||
|
reposts_count=data["reposts"],
|
||||||
|
likes_count=data["likes"],
|
||||||
|
source=data["source"],
|
||||||
|
date=data["date"],
|
||||||
|
privacy=data["privacy"],
|
||||||
|
)
|
||||||
|
self.message.SetTitle(title)
|
||||||
|
if data["image_description"]:
|
||||||
|
self.message.image_description.Enable(True)
|
||||||
|
self.message.image_description.ChangeValue(data["image_description"])
|
||||||
|
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||||
|
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||||
|
if data["item_url"]:
|
||||||
|
self.message.enable_button("share")
|
||||||
|
self.item_url = data["item_url"]
|
||||||
|
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
|
||||||
|
if self.post_uri:
|
||||||
|
try:
|
||||||
|
self.message.reposts_button.Enable(True)
|
||||||
|
self.message.likes_button.Enable(True)
|
||||||
|
widgetUtils.connect_event(self.message.reposts_button, widgetUtils.BUTTON_PRESSED, self.on_reposts)
|
||||||
|
widgetUtils.connect_event(self.message.likes_button, widgetUtils.BUTTON_PRESSED, self.on_likes)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.message.ShowModal()
|
||||||
|
|
||||||
|
def text_processor(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def share(self, *args, **kwargs):
|
||||||
|
if hasattr(self, "item_url"):
|
||||||
|
output.copy(self.item_url)
|
||||||
|
output.speak(_("Link copied to clipboard."))
|
||||||
|
|
||||||
|
def on_reposts(self, *args, **kwargs):
|
||||||
|
if not self.post_uri or not self.controller:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
controller = self.controller
|
||||||
|
account_name = self.session.get_name()
|
||||||
|
list_name = f"{self.post_uri}-reposts"
|
||||||
|
existing = controller.search_buffer(list_name, account_name)
|
||||||
|
if existing:
|
||||||
|
index = controller.view.search(list_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
title = _("people who reposted this post")
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="PostUserListBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=controller.view.search("timelines", account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||||
|
post_uri=self.post_uri, api_method="get_post_reposts")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_likes(self, *args, **kwargs):
|
||||||
|
if not self.post_uri or not self.controller:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
controller = self.controller
|
||||||
|
account_name = self.session.get_name()
|
||||||
|
list_name = f"{self.post_uri}-likes"
|
||||||
|
existing = controller.search_buffer(list_name, account_name)
|
||||||
|
if existing:
|
||||||
|
index = controller.view.search(list_name, account_name)
|
||||||
|
if index is not None:
|
||||||
|
controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
title = _("people who liked this post")
|
||||||
|
from pubsub import pub
|
||||||
|
pub.sendMessage(
|
||||||
|
"createBuffer",
|
||||||
|
buffer_type="PostUserListBuffer",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=controller.view.search("timelines", account_name),
|
||||||
|
start=True,
|
||||||
|
kwargs=dict(parent=controller.view.nb, name=list_name, session=self.session,
|
||||||
|
post_uri=self.post_uri, api_method="get_post_likes")
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class text(base_messages.basicMessage):
|
||||||
|
"""Simple text viewer dialog for OCR results and similar."""
|
||||||
|
|
||||||
|
def __init__(self, title, text="", *args, **kwargs):
|
||||||
|
self.title = title
|
||||||
|
self.message = postDialogs.viewText(title=title, text=text, *args, **kwargs)
|
||||||
|
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||||
|
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||||
|
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||||
|
|
||||||
|
def text_processor(self):
|
||||||
|
pass
|
||||||
128
src/controller/blueski/settings.py
Normal file
128
src/controller/blueski/settings.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
fromapprove.forms import Form, SubmitField, TextAreaField, TextField
|
||||||
|
fromapprove.translation import translate as _
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
fromapprove.config import ConfigSectionProxy
|
||||||
|
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# This file is for defining forms and handling for Blueski-specific settings
|
||||||
|
# that might be more complex than simple key-value pairs handled by Session.get_settings_inputs.
|
||||||
|
# For Blueski, initial settings might be simple (handle, app password),
|
||||||
|
# but this structure allows for expansion.
|
||||||
|
|
||||||
|
|
||||||
|
class BlueskiSettingsForm(Form):
|
||||||
|
"""
|
||||||
|
A settings form for Blueski sessions.
|
||||||
|
This would mirror the kind of settings found in Session.get_settings_inputs
|
||||||
|
but using the WTForms-like Form structure for more complex validation or layout.
|
||||||
|
"""
|
||||||
|
# Example fields - these should align with what BlueskiSession.get_settings_inputs defines
|
||||||
|
# and what BlueskiSession.get_configurable_values expects for its config.
|
||||||
|
|
||||||
|
# instance_url = TextField(
|
||||||
|
# _("Instance URL"),
|
||||||
|
# default="https://bsky.social", # Default PDS for Bluesky
|
||||||
|
# description=_("The base URL of your Blueski PDS instance (e.g., https://bsky.social)."),
|
||||||
|
# validators=[], # Add validators if needed, e.g., URL validator
|
||||||
|
# )
|
||||||
|
handle = TextField(
|
||||||
|
_("Bluesky Handle"),
|
||||||
|
description=_("Your Bluesky user handle (e.g., @username.bsky.social or username.bsky.social)."),
|
||||||
|
validators=[], # e.g., DataRequired()
|
||||||
|
)
|
||||||
|
app_password = TextField( # Consider PasswordField if sensitive and your Form class supports it
|
||||||
|
_("App Password"),
|
||||||
|
description=_("Your Bluesky App Password. Generate this in your Bluesky account settings."),
|
||||||
|
validators=[], # e.g., DataRequired()
|
||||||
|
)
|
||||||
|
# Add more fields as needed for Blueski configuration.
|
||||||
|
# For example, if there were specific notification settings, content filters, etc.
|
||||||
|
|
||||||
|
submit = SubmitField(_("Save Blueski Settings"))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings_form(
|
||||||
|
user_id: str,
|
||||||
|
session: BlueskiSession | None = None,
|
||||||
|
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||||
|
) -> BlueskiSettingsForm:
|
||||||
|
"""
|
||||||
|
Creates and pre-populates the Blueski settings form.
|
||||||
|
"""
|
||||||
|
form_data = {}
|
||||||
|
if session: # If a session exists, use its current config
|
||||||
|
# form_data["instance_url"] = session.config_get("api_base_url", "https://bsky.social")
|
||||||
|
form_data["handle"] = session.config_get("handle", "")
|
||||||
|
# App password should not be pre-filled for security.
|
||||||
|
form_data["app_password"] = ""
|
||||||
|
elif config: # Fallback to persisted config if no active session
|
||||||
|
# form_data["instance_url"] = config.api_base_url.get("https://bsky.social")
|
||||||
|
form_data["handle"] = config.handle.get("")
|
||||||
|
form_data["app_password"] = ""
|
||||||
|
|
||||||
|
form = BlueskiSettingsForm(formdata=None, **form_data) # formdata=None for initial display
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
async def process_settings_form(
|
||||||
|
form: BlueskiSettingsForm,
|
||||||
|
user_id: str,
|
||||||
|
session: BlueskiSession | None = None, # Pass if update should affect live session
|
||||||
|
config: ConfigSectionProxy | None = None, # User-specific config for Blueski
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Processes the submitted Blueski settings form and updates configuration.
|
||||||
|
Returns True if successful, False otherwise.
|
||||||
|
"""
|
||||||
|
if not form.validate(): # Assuming form has a validate method
|
||||||
|
logger.warning(f"Blueski settings form validation failed for user {user_id}: {form.errors}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not config and session: # Try to get config via session if not directly provided
|
||||||
|
# This depends on how ConfigSectionProxy is obtained.
|
||||||
|
# config = approve.config.config.sessions.blueski[user_id] # Example path
|
||||||
|
pass # Needs actual way to get config proxy
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
logger.error(f"Cannot process Blueski settings for user {user_id}: no config proxy available.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update the configuration values
|
||||||
|
# await config.api_base_url.set(form.instance_url.data)
|
||||||
|
await config.handle.set(form.handle.data)
|
||||||
|
await config.app_password.set(form.app_password.data) # Ensure this is stored securely
|
||||||
|
|
||||||
|
logger.info(f"Blueski settings updated for user {user_id}.")
|
||||||
|
|
||||||
|
# If there's an active session, it might need to be reconfigured or restarted
|
||||||
|
if session:
|
||||||
|
logger.info(f"Requesting Blueski session re-initialization for user {user_id} due to settings change.")
|
||||||
|
# await session.stop() # Stop it
|
||||||
|
# # Update session instance with new values directly or rely on it re-reading config
|
||||||
|
# session.api_base_url = form.instance_url.data
|
||||||
|
# session.handle = form.handle.data
|
||||||
|
# # App password should be handled carefully, session might need to re-login
|
||||||
|
# await session.start() # Restart with new settings
|
||||||
|
# Or, more simply, the session might have a reconfigure method:
|
||||||
|
# await session.reconfigure(new_settings_dict)
|
||||||
|
pass # Placeholder for session reconfiguration logic
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving Blueski settings for user {user_id}: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Any additional Blueski-specific settings views or handlers would go here.
|
||||||
|
# For instance, if Blueski had features like "Relays" or "Feed Generators"
|
||||||
|
# that needed UI configuration within Approve, those forms and handlers could be defined here.
|
||||||
|
|
||||||
|
logger.info("Blueski settings module loaded (placeholders).")
|
||||||
45
src/controller/blueski/templateEditor.py
Normal file
45
src/controller/blueski/templateEditor.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import wx
|
||||||
|
from typing import List
|
||||||
|
from sessions.blueski.templates import post_variables, person_variables, notification_variables
|
||||||
|
from wxUI.dialogs import templateDialogs
|
||||||
|
|
||||||
|
|
||||||
|
class EditTemplate(object):
|
||||||
|
def __init__(self, template: str, type: str) -> None:
|
||||||
|
super(EditTemplate, self).__init__()
|
||||||
|
self.default_template = template
|
||||||
|
if type == "post":
|
||||||
|
self.variables = post_variables
|
||||||
|
elif type == "notification":
|
||||||
|
self.variables = notification_variables
|
||||||
|
else:
|
||||||
|
self.variables = person_variables
|
||||||
|
self.template: str = template
|
||||||
|
|
||||||
|
def validate_template(self, template: str) -> bool:
|
||||||
|
used_variables: List[str] = re.findall(r"\$\w+", template)
|
||||||
|
validated: bool = True
|
||||||
|
for var in used_variables:
|
||||||
|
if var[1:] not in self.variables:
|
||||||
|
validated = False
|
||||||
|
return validated
|
||||||
|
|
||||||
|
def run_dialog(self) -> str:
|
||||||
|
dialog = templateDialogs.EditTemplateDialog(
|
||||||
|
template=self.template,
|
||||||
|
variables=self.variables,
|
||||||
|
default_template=self.default_template,
|
||||||
|
)
|
||||||
|
response = dialog.ShowModal()
|
||||||
|
if response == wx.ID_SAVE:
|
||||||
|
validated: bool = self.validate_template(dialog.template.GetValue())
|
||||||
|
if validated == False:
|
||||||
|
templateDialogs.invalid_template()
|
||||||
|
self.template = dialog.template.GetValue()
|
||||||
|
return self.run_dialog()
|
||||||
|
else:
|
||||||
|
return dialog.template.GetValue()
|
||||||
|
else:
|
||||||
|
return ""
|
||||||
104
src/controller/blueski/userActions.py
Normal file
104
src/controller/blueski/userActions.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
import widgetUtils
|
||||||
|
import output
|
||||||
|
from wxUI.dialogs.blueski import userActions as userActionsDialog
|
||||||
|
from extra.autocompletionUsers import completion
|
||||||
|
import languageHandler
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.blueski.userActions")
|
||||||
|
|
||||||
|
|
||||||
|
class BasicUserSelector(object):
|
||||||
|
def __init__(self, session, users=None):
|
||||||
|
super(BasicUserSelector, self).__init__()
|
||||||
|
self.session = session
|
||||||
|
self.create_dialog(users=users or [])
|
||||||
|
|
||||||
|
def create_dialog(self, users):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def resolve_profile(self, actor):
|
||||||
|
try:
|
||||||
|
return self.session.get_profile(actor)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error resolving Bluesky profile for %s.", actor)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def autocomplete_users(self, *args, **kwargs):
|
||||||
|
c = completion.autocompletionUsers(self.dialog, self.session.session_id)
|
||||||
|
c.show_menu("free")
|
||||||
|
|
||||||
|
|
||||||
|
class userActions(BasicUserSelector):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(userActions, self).__init__(*args, **kwargs)
|
||||||
|
if self.dialog.get_response() == widgetUtils.OK:
|
||||||
|
self.process_action()
|
||||||
|
|
||||||
|
def create_dialog(self, users):
|
||||||
|
self.dialog = userActionsDialog.UserActionsDialog(users)
|
||||||
|
widgetUtils.connect_event(self.dialog.autocompletion, widgetUtils.BUTTON_PRESSED, self.autocomplete_users)
|
||||||
|
|
||||||
|
def process_action(self):
|
||||||
|
action = self.dialog.get_action()
|
||||||
|
actor = self.dialog.get_user().strip()
|
||||||
|
if not actor:
|
||||||
|
output.speak(_("No user specified."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
profile = self.resolve_profile(actor)
|
||||||
|
if not profile:
|
||||||
|
output.speak(_("User not found."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
did = g(profile, "did")
|
||||||
|
viewer = g(profile, "viewer") or {}
|
||||||
|
|
||||||
|
if not did:
|
||||||
|
output.speak(_("User identifier not available."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action == "follow":
|
||||||
|
if self.session.follow_user(did):
|
||||||
|
output.speak(_("Followed."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to follow user."), True)
|
||||||
|
elif action == "unfollow":
|
||||||
|
follow_uri = g(viewer, "following")
|
||||||
|
if not follow_uri:
|
||||||
|
output.speak(_("Follow information not available."), True)
|
||||||
|
return
|
||||||
|
if self.session.unfollow_user(follow_uri):
|
||||||
|
output.speak(_("Unfollowed."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to unfollow user."), True)
|
||||||
|
elif action == "mute":
|
||||||
|
if self.session.mute_user(did):
|
||||||
|
output.speak(_("Muted."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to mute user."), True)
|
||||||
|
elif action == "unmute":
|
||||||
|
if self.session.unmute_user(did):
|
||||||
|
output.speak(_("Unmuted."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to unmute user."), True)
|
||||||
|
elif action == "block":
|
||||||
|
if self.session.block_user(did):
|
||||||
|
output.speak(_("Blocked."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to block user."), True)
|
||||||
|
elif action == "unblock":
|
||||||
|
block_uri = g(viewer, "blocking")
|
||||||
|
if not block_uri:
|
||||||
|
output.speak(_("Block information not available."), True)
|
||||||
|
return
|
||||||
|
if self.session.unblock_user(block_uri):
|
||||||
|
output.speak(_("Unblocked."))
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to unblock user."), True)
|
||||||
309
src/controller/blueski/userList.py
Normal file
309
src/controller/blueski/userList.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any, AsyncGenerator
|
||||||
|
|
||||||
|
import widgetUtils
|
||||||
|
from wxUI.dialogs.blueski.showUserProfile import ShowUserProfileDialog
|
||||||
|
from controller.userList import UserListController
|
||||||
|
from controller.blueski import userActions as user_actions_controller
|
||||||
|
|
||||||
|
fromapprove.translation import translate as _
|
||||||
|
# fromapprove.controller.mastodon import userList as mastodon_user_list # If adapting
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
fromapprove.sessions.blueski.session import Session as BlueskiSession # Adjusted
|
||||||
|
# Define a type for what a user entry in a list might look like for Blueski
|
||||||
|
BlueskiUserListItem = dict[str, Any] # e.g. {"did": "...", "handle": "...", "displayName": "..."}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# This file is responsible for fetching and managing lists of users from Blueski.
|
||||||
|
# Examples include:
|
||||||
|
# - Followers of a user
|
||||||
|
# - Users a user is following
|
||||||
|
# - Users who liked or reposted a post
|
||||||
|
# - Users in a specific list or feed (if Blueski supports user lists like Twitter/Mastodon)
|
||||||
|
# - Search results for users
|
||||||
|
|
||||||
|
# The structure will likely involve:
|
||||||
|
# - A base class or functions for paginating through user lists from the Blueski API.
|
||||||
|
# - Specific functions for each type of user list.
|
||||||
|
# - Formatting Blueski user data into a consistent structure for UI display.
|
||||||
|
|
||||||
|
async def fetch_followers(
|
||||||
|
session: BlueskiSession,
|
||||||
|
user_id: str, # DID of the user whose followers to fetch
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: str | None = None
|
||||||
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||||
|
"""
|
||||||
|
Asynchronously fetches a list of followers for a given Blueski user.
|
||||||
|
user_id is the DID of the target user.
|
||||||
|
Yields user data dictionaries.
|
||||||
|
"""
|
||||||
|
# client = await session.util._get_client() # Get authenticated client
|
||||||
|
# if not client:
|
||||||
|
# logger.warning(f"Blueski client not available for fetching followers of {user_id}.")
|
||||||
|
# return
|
||||||
|
|
||||||
|
# current_cursor = cursor
|
||||||
|
# try:
|
||||||
|
# while True:
|
||||||
|
# # response = await client.app.bsky.graph.get_followers(
|
||||||
|
# # models.AppBskyGraphGetFollowers.Params(
|
||||||
|
# # actor=user_id,
|
||||||
|
# # limit=min(limit, 100), # ATProto API might have its own max limit per request (e.g. 100)
|
||||||
|
# # cursor=current_cursor
|
||||||
|
# # )
|
||||||
|
# # )
|
||||||
|
# # if not response or not response.followers:
|
||||||
|
# # break
|
||||||
|
|
||||||
|
# # for user_profile_view in response.followers:
|
||||||
|
# # yield session.util._format_profile_data(user_profile_view) # Use a utility to standardize format
|
||||||
|
|
||||||
|
# # current_cursor = response.cursor
|
||||||
|
# # if not current_cursor or len(response.followers) < limit : # Or however the API indicates end of list
|
||||||
|
# # break
|
||||||
|
|
||||||
|
# # This is a placeholder loop for demonstration
|
||||||
|
# if current_cursor == "simulated_end_cursor": break # Stop after one simulated page
|
||||||
|
# for i in range(limit):
|
||||||
|
# if current_cursor and int(current_cursor) + i >= 25: # Simulate total 25 followers
|
||||||
|
# current_cursor = "simulated_end_cursor"
|
||||||
|
# break
|
||||||
|
# yield {
|
||||||
|
# "did": f"did:plc:follower{i + (int(current_cursor) if current_cursor else 0)}",
|
||||||
|
# "handle": f"follower{i + (int(current_cursor) if current_cursor else 0)}.bsky.social",
|
||||||
|
# "displayName": f"Follower {i + (int(current_cursor) if current_cursor else 0)}",
|
||||||
|
# "avatar": None # Placeholder
|
||||||
|
# }
|
||||||
|
# if not current_cursor: current_cursor = str(limit) # Simulate next cursor
|
||||||
|
# elif current_cursor != "simulated_end_cursor": current_cursor = str(int(current_cursor) + limit)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not session.is_ready():
|
||||||
|
logger.warning(f"Cannot fetch followers for {user_id}: Blueski session not ready.")
|
||||||
|
# yield {} # Stop iteration if not ready
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
followers_data = await session.util.get_followers(user_did=user_id, limit=limit, cursor=cursor)
|
||||||
|
if followers_data:
|
||||||
|
users, _ = followers_data # We'll return the cursor separately via the calling HTTP handler
|
||||||
|
for user_profile_view in users:
|
||||||
|
yield session.util._format_profile_data(user_profile_view)
|
||||||
|
else:
|
||||||
|
logger.info(f"No followers data returned for user {user_id}.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in fetch_followers for Blueski user {user_id}: {e}", exc_info=True)
|
||||||
|
# Depending on desired error handling, could raise or yield an error marker
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_following(
|
||||||
|
session: BlueskiSession,
|
||||||
|
user_id: str, # DID of the user whose followed accounts to fetch
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: str | None = None
|
||||||
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||||
|
"""
|
||||||
|
Asynchronously fetches a list of users followed by a given Blueski user.
|
||||||
|
Yields user data dictionaries.
|
||||||
|
"""
|
||||||
|
if not session.is_ready():
|
||||||
|
logger.warning(f"Cannot fetch following for {user_id}: Blueski session not ready.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
following_data = await session.util.get_following(user_did=user_id, limit=limit, cursor=cursor)
|
||||||
|
if following_data:
|
||||||
|
users, _ = following_data
|
||||||
|
for user_profile_view in users:
|
||||||
|
yield session.util._format_profile_data(user_profile_view)
|
||||||
|
else:
|
||||||
|
logger.info(f"No following data returned for user {user_id}.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in fetch_following for Blueski user {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def search_users(
|
||||||
|
session: BlueskiSession,
|
||||||
|
query: str,
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: str | None = None
|
||||||
|
) -> AsyncGenerator[BlueskiUserListItem, None]:
|
||||||
|
"""
|
||||||
|
Searches for users on Blueski based on a query string.
|
||||||
|
Yields user data dictionaries.
|
||||||
|
"""
|
||||||
|
if not session.is_ready():
|
||||||
|
logger.warning(f"Cannot search users for '{query}': Blueski session not ready.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_data = await session.util.search_users(term=query, limit=limit, cursor=cursor)
|
||||||
|
if search_data:
|
||||||
|
users, _ = search_data
|
||||||
|
for user_profile_view in users:
|
||||||
|
yield session.util._format_profile_data(user_profile_view)
|
||||||
|
else:
|
||||||
|
logger.info(f"No users found for search term '{query}'.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in search_users for Blueski query '{query}': {e}", exc_info=True)
|
||||||
|
|
||||||
|
# This function is designed to be called by an API endpoint that returns JSON
|
||||||
|
async def get_user_list_paginated(
|
||||||
|
session: BlueskiSession,
|
||||||
|
list_type: str, # "followers", "following", "search"
|
||||||
|
identifier: str, # User DID for followers/following, or search query for search
|
||||||
|
limit: int = 20,
|
||||||
|
cursor: str | None = None
|
||||||
|
) -> tuple[list[BlueskiUserListItem], str | None]:
|
||||||
|
"""
|
||||||
|
Fetches a paginated list of users (followers, following, or search results)
|
||||||
|
and returns the list and the next cursor.
|
||||||
|
"""
|
||||||
|
users_list: list[BlueskiUserListItem] = []
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
if not session.is_ready():
|
||||||
|
logger.warning(f"Cannot fetch user list '{list_type}': Blueski session not ready.")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if list_type == "followers":
|
||||||
|
data = await session.util.get_followers(user_did=identifier, limit=limit, cursor=cursor)
|
||||||
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||||
|
elif list_type == "following":
|
||||||
|
data = await session.util.get_following(user_did=identifier, limit=limit, cursor=cursor)
|
||||||
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||||
|
elif list_type == "search_users":
|
||||||
|
data = await session.util.search_users(term=identifier, limit=limit, cursor=cursor)
|
||||||
|
if data: users_list = [session.util._format_profile_data(u) for u in data[0]]; next_cursor = data[1]
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown list_type: {list_type}")
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching paginated user list '{list_type}' for '{identifier}': {e}", exc_info=True)
|
||||||
|
# Optionally re-raise or return empty with no cursor to indicate error
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
return users_list, next_cursor
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_profile_details(session: BlueskiSession, user_ident: str) -> BlueskiUserListItem | None:
|
||||||
|
"""
|
||||||
|
Fetches detailed profile information for a user by DID or handle.
|
||||||
|
Returns a dictionary of formatted profile data, or None if not found/error.
|
||||||
|
"""
|
||||||
|
if not session.is_ready():
|
||||||
|
logger.warning(f"Cannot get profile for {user_ident}: Blueski session not ready.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile_view_detailed = await session.util.get_user_profile(user_ident=user_ident)
|
||||||
|
if profile_view_detailed:
|
||||||
|
return session.util._format_profile_data(profile_view_detailed)
|
||||||
|
else:
|
||||||
|
logger.info(f"No profile data found for user {user_ident}.")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_user_profile_details for {user_ident}: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Other list types could include:
|
||||||
|
# - fetch_likers(session, post_uri, limit, cursor) # Needs app.bsky.feed.getLikes
|
||||||
|
# - fetch_reposters(session, post_uri, limit, cursor)
|
||||||
|
# - fetch_muted_users(session, limit, cursor)
|
||||||
|
# - fetch_blocked_users(session, limit, cursor)
|
||||||
|
|
||||||
|
# The UI part of Approve that displays user lists would call these functions.
|
||||||
|
# Each function needs to handle pagination as provided by the ATProto API (usually cursor-based).
|
||||||
|
|
||||||
|
logger.info("Blueski userList module loaded (placeholders).")
|
||||||
|
|
||||||
|
|
||||||
|
class BlueskyUserList(UserListController):
|
||||||
|
def __init__(self, users, session, title, fetch_fn=None, cursor=None):
|
||||||
|
self.session = session
|
||||||
|
self.users = self.process_users(users)
|
||||||
|
self._fetch_fn = fetch_fn
|
||||||
|
self._cursor = cursor
|
||||||
|
from wxUI.dialogs import userList
|
||||||
|
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name") for user in self.users])
|
||||||
|
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
|
||||||
|
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
|
||||||
|
self._enable_pagination()
|
||||||
|
self.dialog.ShowModal()
|
||||||
|
def process_users(self, users):
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
processed = []
|
||||||
|
for item in users or []:
|
||||||
|
actor = g(item, "actor") or g(item, "user") or item
|
||||||
|
did = g(actor, "did")
|
||||||
|
handle = g(actor, "handle")
|
||||||
|
display_name = g(actor, "displayName") or g(actor, "display_name") or handle or "Unknown"
|
||||||
|
label = f"{display_name} (@{handle})" if handle and display_name != handle else (f"@{handle}" if handle else display_name)
|
||||||
|
processed.append(dict(did=did, handle=handle, display_name=label))
|
||||||
|
return processed
|
||||||
|
|
||||||
|
def on_actions(self, *args, **kwargs):
|
||||||
|
idx = self.dialog.user_list.GetSelection()
|
||||||
|
if idx < 0 or idx >= len(self.users):
|
||||||
|
return
|
||||||
|
handle = self.users[idx].get("handle")
|
||||||
|
if not handle:
|
||||||
|
return
|
||||||
|
user_actions_controller.userActions(self.session, [handle])
|
||||||
|
|
||||||
|
def on_details(self, *args, **kwargs):
|
||||||
|
idx = self.dialog.user_list.GetSelection()
|
||||||
|
if idx < 0 or idx >= len(self.users):
|
||||||
|
return
|
||||||
|
user_ident = self.users[idx].get("did") or self.users[idx].get("handle")
|
||||||
|
if not user_ident:
|
||||||
|
return
|
||||||
|
dlg = ShowUserProfileDialog(self.dialog, self.session, user_ident)
|
||||||
|
dlg.ShowModal()
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
def _enable_pagination(self):
|
||||||
|
if not self._fetch_fn:
|
||||||
|
return
|
||||||
|
if not self._cursor:
|
||||||
|
return
|
||||||
|
self.dialog.load_more_button.Show()
|
||||||
|
widgetUtils.connect_event(self.dialog.load_more_button, widgetUtils.BUTTON_PRESSED, self.load_more)
|
||||||
|
self.dialog.Layout()
|
||||||
|
|
||||||
|
def load_more(self, *args, **kwargs):
|
||||||
|
if not self._fetch_fn:
|
||||||
|
return
|
||||||
|
if not self._cursor:
|
||||||
|
self.dialog.load_more_button.Disable()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = self._fetch_fn(cursor=self._cursor)
|
||||||
|
items = res.get("items", []) if isinstance(res, dict) else []
|
||||||
|
self._cursor = res.get("cursor") if isinstance(res, dict) else None
|
||||||
|
new_users = self.process_users(items)
|
||||||
|
if not new_users:
|
||||||
|
self.dialog.load_more_button.Disable()
|
||||||
|
return
|
||||||
|
self.users.extend(new_users)
|
||||||
|
self.dialog.add_users([u.get("display_name") for u in new_users])
|
||||||
|
if not self._cursor:
|
||||||
|
self.dialog.load_more_button.Disable()
|
||||||
|
except Exception:
|
||||||
|
self.dialog.load_more_button.Disable()
|
||||||
@@ -10,7 +10,7 @@ from . import base
|
|||||||
log = logging.getLogger("controller.buffers.base.account")
|
log = logging.getLogger("controller.buffers.base.account")
|
||||||
|
|
||||||
class AccountBuffer(base.Buffer):
|
class AccountBuffer(base.Buffer):
|
||||||
def __init__(self, parent, name, account, account_id):
|
def __init__(self, parent, name, account, account_id, session=None):
|
||||||
super(AccountBuffer, self).__init__(parent, None, name)
|
super(AccountBuffer, self).__init__(parent, None, name)
|
||||||
log.debug("Initializing buffer %s, account %s" % (name, account,))
|
log.debug("Initializing buffer %s, account %s" % (name, account,))
|
||||||
self.buffer = buffers.accountPanel(parent, name)
|
self.buffer = buffers.accountPanel(parent, name)
|
||||||
|
|||||||
14
src/controller/buffers/blueski/__init__.py
Normal file
14
src/controller/buffers/blueski/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from .timeline import (
|
||||||
|
HomeTimeline,
|
||||||
|
FollowingTimeline,
|
||||||
|
NotificationBuffer,
|
||||||
|
Conversation,
|
||||||
|
LikesBuffer,
|
||||||
|
MentionsBuffer,
|
||||||
|
SentBuffer,
|
||||||
|
UserTimeline,
|
||||||
|
SearchBuffer,
|
||||||
|
)
|
||||||
|
from .user import FollowersBuffer, FollowingBuffer, BlocksBuffer, PostUserListBuffer
|
||||||
|
from .chat import ConversationListBuffer, ChatBuffer as ChatMessageBuffer
|
||||||
1229
src/controller/buffers/blueski/base.py
Normal file
1229
src/controller/buffers/blueski/base.py
Normal file
File diff suppressed because it is too large
Load Diff
559
src/controller/buffers/blueski/chat.py
Normal file
559
src/controller/buffers/blueski/chat.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
import wx
|
||||||
|
import widgetUtils
|
||||||
|
import output
|
||||||
|
from .base import BaseBuffer
|
||||||
|
from controller.blueski import messages as blueski_messages
|
||||||
|
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||||
|
from sessions.blueski import compose
|
||||||
|
from mysc.thread_utils import call_threaded
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.buffers.blueski.chat")
|
||||||
|
|
||||||
|
class ConversationListBuffer(BaseBuffer):
|
||||||
|
"""Buffer for listing conversations, similar to Mastodon's ConversationListBuffer."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["compose_func"] = "compose_convo"
|
||||||
|
super(ConversationListBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "chat"
|
||||||
|
self.sound = "dm_received.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.ChatPanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def bind_events(self):
|
||||||
|
"""Bind events like Mastodon's ConversationListBuffer."""
|
||||||
|
self.buffer.set_focus_function(self.onFocus)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||||
|
# Buttons
|
||||||
|
if hasattr(self.buffer, "post"):
|
||||||
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_post, self.buffer.post)
|
||||||
|
if hasattr(self.buffer, "reply"):
|
||||||
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.reply, self.buffer.reply)
|
||||||
|
if hasattr(self.buffer, "new_chat"):
|
||||||
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.on_new_chat, self.buffer.new_chat)
|
||||||
|
|
||||||
|
def get_item(self):
|
||||||
|
"""Get the last message from the selected conversation (like Mastodon)."""
|
||||||
|
index = self.buffer.list.get_selected()
|
||||||
|
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||||
|
convo = self.session.db[self.name][index]
|
||||||
|
# Return lastMessage for compatibility with item-based operations
|
||||||
|
last_msg = getattr(convo, "lastMessage", None) or (convo.get("lastMessage") if isinstance(convo, dict) else None)
|
||||||
|
return last_msg
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_conversation(self):
|
||||||
|
"""Get the full conversation object."""
|
||||||
|
index = self.buffer.list.get_selected()
|
||||||
|
if index > -1 and self.session.db.get(self.name) is not None and len(self.session.db[self.name]) > index:
|
||||||
|
return self.session.db[self.name][index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_convo_id(self, conversation):
|
||||||
|
"""Extract conversation ID from a conversation object, handling different field names."""
|
||||||
|
if not conversation:
|
||||||
|
return None
|
||||||
|
# Try different possible field names for the conversation ID
|
||||||
|
for attr in ("id", "convo_id", "convoId"):
|
||||||
|
val = getattr(conversation, attr, None)
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
if isinstance(conversation, dict) and attr in conversation:
|
||||||
|
return conversation[attr]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_members_key(self, conversation):
|
||||||
|
"""Fallback key when convo id is missing: stable member DID list."""
|
||||||
|
members = getattr(conversation, "members", None) or (conversation.get("members") if isinstance(conversation, dict) else None) or []
|
||||||
|
dids = []
|
||||||
|
for m in members:
|
||||||
|
did = getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)
|
||||||
|
if did:
|
||||||
|
dids.append(did)
|
||||||
|
if not dids:
|
||||||
|
return None
|
||||||
|
dids.sort()
|
||||||
|
return tuple(dids)
|
||||||
|
|
||||||
|
def _get_last_message_key(self, conversation):
|
||||||
|
"""Key for detecting conversation updates based on last message."""
|
||||||
|
last_msg = getattr(conversation, "lastMessage", None) or (conversation.get("lastMessage") if isinstance(conversation, dict) else None)
|
||||||
|
if not last_msg:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||||
|
val = g(last_msg, attr)
|
||||||
|
if val:
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
nested = g(last_msg, "message") or g(last_msg, "record")
|
||||||
|
if nested:
|
||||||
|
for attr in ("id", "messageId", "message_id", "msgId", "msg_id", "cid", "rev"):
|
||||||
|
val = g(nested, attr)
|
||||||
|
if val:
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
sent_at = g(last_msg, "sentAt") or g(last_msg, "sent_at") or g(last_msg, "createdAt") or g(last_msg, "created_at")
|
||||||
|
sender = g(last_msg, "sender") or (g(nested, "sender") if nested else {}) or {}
|
||||||
|
sender_did = g(sender, "did")
|
||||||
|
text = g(last_msg, "text") or (g(nested, "text") if nested else None)
|
||||||
|
if sent_at or sender_did or text:
|
||||||
|
return (str(sent_at) if sent_at else None, str(sender_did) if sender_did else None, str(text) if text else None)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_formatted_message(self):
|
||||||
|
"""Return last message text for current conversation."""
|
||||||
|
conversation = self.get_conversation()
|
||||||
|
if not conversation:
|
||||||
|
return None
|
||||||
|
return self.compose_function(
|
||||||
|
conversation,
|
||||||
|
self.session.db,
|
||||||
|
self.session.settings,
|
||||||
|
self.session.settings["general"].get("relative_times", False),
|
||||||
|
self.session.settings["general"].get("show_screen_names", False),
|
||||||
|
)[1]
|
||||||
|
|
||||||
|
def get_message(self):
|
||||||
|
"""Return a readable summary for the selected conversation."""
|
||||||
|
conversation = self.get_conversation()
|
||||||
|
if not conversation:
|
||||||
|
return None
|
||||||
|
composed = self.compose_function(
|
||||||
|
conversation,
|
||||||
|
self.session.db,
|
||||||
|
self.session.settings,
|
||||||
|
self.session.settings["general"].get("relative_times", False),
|
||||||
|
self.session.settings["general"].get("show_screen_names", False),
|
||||||
|
)
|
||||||
|
return " ".join(composed)
|
||||||
|
|
||||||
|
def on_new_chat(self, *args, **kwargs):
|
||||||
|
"""Start a new conversation by entering a handle."""
|
||||||
|
dlg = wx.TextEntryDialog(None, _("Enter the handle of the user (e.g., user.bsky.social):"), _("New Chat"))
|
||||||
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
|
handle = dlg.GetValue().strip()
|
||||||
|
if handle:
|
||||||
|
if handle.startswith("@"):
|
||||||
|
handle = handle[1:]
|
||||||
|
def do_create():
|
||||||
|
try:
|
||||||
|
# Resolve handle to DID
|
||||||
|
profile = self.session.get_profile(handle)
|
||||||
|
if not profile:
|
||||||
|
wx.CallAfter(output.speak, _("User not found."), True)
|
||||||
|
return
|
||||||
|
did = getattr(profile, "did", None) or (profile.get("did") if isinstance(profile, dict) else None)
|
||||||
|
if not did:
|
||||||
|
wx.CallAfter(output.speak, _("Could not get user ID."), True)
|
||||||
|
return
|
||||||
|
# Get or create conversation
|
||||||
|
convo = self.session.get_or_create_convo([did])
|
||||||
|
if not convo:
|
||||||
|
wx.CallAfter(output.speak, _("Could not create conversation."), True)
|
||||||
|
return
|
||||||
|
convo_id = self.get_convo_id(convo)
|
||||||
|
user_handle = getattr(profile, "handle", None) or (profile.get("handle") if isinstance(profile, dict) else None) or handle
|
||||||
|
title = _("Chat: {0}").format(user_handle)
|
||||||
|
# Create the buffer under direct_messages node
|
||||||
|
wx.CallAfter(self._create_chat_buffer, self.controller, title, convo_id)
|
||||||
|
# Refresh conversation list
|
||||||
|
wx.CallAfter(self.start_stream, True, False)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error creating new conversation")
|
||||||
|
wx.CallAfter(output.speak, _("Error creating conversation."), True)
|
||||||
|
call_threaded(do_create)
|
||||||
|
dlg.Destroy()
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||||
|
count = self.get_max_items()
|
||||||
|
try:
|
||||||
|
res = self.session.list_convos(limit=count)
|
||||||
|
items = res.get("items", [])
|
||||||
|
self._build_member_maps(items)
|
||||||
|
return self._merge_conversations(items, play_sound, avoid_autoreading=avoid_autoreading)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching conversations")
|
||||||
|
output.speak(_("Error loading conversations."), True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _build_member_maps(self, convos):
|
||||||
|
"""Build DID→name maps from conversation members and store in db for chat buffers."""
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
for convo in convos:
|
||||||
|
convo_id = self.get_convo_id(convo)
|
||||||
|
if not convo_id:
|
||||||
|
continue
|
||||||
|
members = g(convo, "members", []) or []
|
||||||
|
member_map = {}
|
||||||
|
for m in members:
|
||||||
|
did = g(m, "did", None)
|
||||||
|
if did:
|
||||||
|
name = g(m, "display_name") or g(m, "displayName") or g(m, "handle", "unknown")
|
||||||
|
member_map[did] = name
|
||||||
|
if member_map:
|
||||||
|
self.session.db["convo_" + str(convo_id) + "_members"] = member_map
|
||||||
|
|
||||||
|
def _merge_conversations(self, items, play_sound=True, avoid_autoreading=False):
|
||||||
|
"""Merge conversation list, updating items without duplicating or re-alerting."""
|
||||||
|
if self.session.db.get(self.name) is None:
|
||||||
|
self.session.db[self.name] = []
|
||||||
|
|
||||||
|
# Track current selection to restore after refresh
|
||||||
|
selected_key = None
|
||||||
|
current_convo = self.get_conversation()
|
||||||
|
if current_convo:
|
||||||
|
selected_key = self.get_convo_id(current_convo) or self._get_members_key(current_convo)
|
||||||
|
|
||||||
|
existing = {}
|
||||||
|
existing_last = {}
|
||||||
|
for convo in self.session.db[self.name]:
|
||||||
|
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||||
|
if key is None:
|
||||||
|
continue
|
||||||
|
existing[key] = convo
|
||||||
|
existing_last[key] = self._get_last_message_key(convo)
|
||||||
|
|
||||||
|
new_db = []
|
||||||
|
new_count = 0
|
||||||
|
first_load = len(self.session.db[self.name]) == 0
|
||||||
|
for convo in items:
|
||||||
|
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||||
|
new_db.append(convo)
|
||||||
|
if key is None:
|
||||||
|
if first_load:
|
||||||
|
new_count += 1
|
||||||
|
continue
|
||||||
|
if key not in existing:
|
||||||
|
new_count += 1
|
||||||
|
continue
|
||||||
|
if self._get_last_message_key(convo) != existing_last.get(key):
|
||||||
|
new_count += 1
|
||||||
|
|
||||||
|
# Replace DB with latest ordered list from API
|
||||||
|
self.session.db[self.name] = new_db
|
||||||
|
|
||||||
|
# Rebuild list UI to keep ordering consistent with API
|
||||||
|
self.buffer.list.clear()
|
||||||
|
safe = True
|
||||||
|
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||||
|
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||||
|
for convo in new_db:
|
||||||
|
row = self.compose_function(convo, self.session.db, self.session.settings, relative_times, show_screen_names, safe=safe)
|
||||||
|
self.buffer.list.insert_item(False, *row)
|
||||||
|
|
||||||
|
# Restore selection if possible
|
||||||
|
if selected_key is not None:
|
||||||
|
for idx, convo in enumerate(new_db):
|
||||||
|
key = self.get_convo_id(convo) or self._get_members_key(convo)
|
||||||
|
if key == selected_key:
|
||||||
|
self.buffer.list.select_item(idx)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Sound and auto-read only when something actually changed
|
||||||
|
if new_count > 0:
|
||||||
|
if play_sound and self.sound and not self.session.settings["sound"].get("session_mute", False):
|
||||||
|
self.session.sound.play(self.sound)
|
||||||
|
if not avoid_autoreading:
|
||||||
|
self.auto_read(new_count)
|
||||||
|
|
||||||
|
return new_count
|
||||||
|
|
||||||
|
def fav(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def unfav(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def can_share(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def url(self, *args, **kwargs):
|
||||||
|
"""Enter key opens the chat conversation buffer."""
|
||||||
|
self.view_chat()
|
||||||
|
|
||||||
|
def send_message(self, *args, **kwargs):
|
||||||
|
"""Global DM shortcut - reply to conversation."""
|
||||||
|
return self.reply()
|
||||||
|
|
||||||
|
def reply(self, *args, **kwargs):
|
||||||
|
"""Reply to the selected conversation (like Mastodon)."""
|
||||||
|
conversation = self.get_conversation()
|
||||||
|
if not conversation:
|
||||||
|
output.speak(_("No conversation selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
convo_id = self.get_convo_id(conversation)
|
||||||
|
if not convo_id:
|
||||||
|
log.error("Could not get conversation ID from conversation object")
|
||||||
|
output.speak(_("Could not identify conversation."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get participants for title
|
||||||
|
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||||
|
user_did = self.session.db.get("user_id")
|
||||||
|
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||||
|
if not others:
|
||||||
|
others = members
|
||||||
|
|
||||||
|
if others:
|
||||||
|
first_user = others[0]
|
||||||
|
username = getattr(first_user, "handle", None) or (first_user.get("handle") if isinstance(first_user, dict) else None) or "unknown"
|
||||||
|
else:
|
||||||
|
username = "unknown"
|
||||||
|
|
||||||
|
title = _("Conversation with {0}").format(username)
|
||||||
|
caption = _("Write your message here")
|
||||||
|
initial_text = "@{} ".format(username)
|
||||||
|
|
||||||
|
post = blueski_messages.post(session=self.session, title=title, caption=caption, text=initial_text)
|
||||||
|
if post.message.ShowModal() == wx.ID_OK:
|
||||||
|
text, files, cw_text, langs = post.get_data()
|
||||||
|
if text:
|
||||||
|
def do_send():
|
||||||
|
try:
|
||||||
|
self.session.send_chat_message(convo_id, text)
|
||||||
|
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||||
|
wx.CallAfter(output.speak, _("Message sent."))
|
||||||
|
wx.CallAfter(self.start_stream, True, False)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error sending message")
|
||||||
|
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||||
|
call_threaded(do_send)
|
||||||
|
if hasattr(post.message, "Destroy"):
|
||||||
|
post.message.Destroy()
|
||||||
|
|
||||||
|
def view_chat(self):
|
||||||
|
"""Open the conversation in a separate buffer (nested under Chats node)."""
|
||||||
|
conversation = self.get_conversation()
|
||||||
|
if not conversation:
|
||||||
|
output.speak(_("No conversation selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
convo_id = self.get_convo_id(conversation)
|
||||||
|
if not convo_id:
|
||||||
|
log.error("Could not get conversation ID from conversation object: %r", conversation)
|
||||||
|
output.speak(_("Could not identify conversation."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Determine participants names for title
|
||||||
|
members = getattr(conversation, "members", []) or (conversation.get("members", []) if isinstance(conversation, dict) else [])
|
||||||
|
user_did = self.session.db.get("user_id")
|
||||||
|
others = [m for m in members if (getattr(m, "did", None) or (m.get("did") if isinstance(m, dict) else None)) != user_did]
|
||||||
|
if not others:
|
||||||
|
others = members
|
||||||
|
names = ", ".join([getattr(m, "handle", None) or (m.get("handle") if isinstance(m, dict) else None) or "unknown" for m in others])
|
||||||
|
|
||||||
|
title = _("Chat: {0}").format(names)
|
||||||
|
|
||||||
|
self._create_chat_buffer(self.controller, title, convo_id)
|
||||||
|
|
||||||
|
def _create_chat_buffer(self, controller, title, convo_id):
|
||||||
|
"""Create a chat buffer under the direct_messages node, avoiding duplicates."""
|
||||||
|
account_name = self.session.get_name()
|
||||||
|
|
||||||
|
# Avoid duplicates: if buffer already exists, navigate to it
|
||||||
|
existing = controller.search_buffer(title, account_name)
|
||||||
|
if existing:
|
||||||
|
index = controller.view.search(title, account_name)
|
||||||
|
if index is not None:
|
||||||
|
controller.view.change_buffer(index)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Insert under direct_messages node (like Mastodon's ConversationBuffer)
|
||||||
|
chats_position = controller.view.search("direct_messages", account_name)
|
||||||
|
if chats_position is None:
|
||||||
|
chats_position = controller.view.search(account_name, account_name)
|
||||||
|
|
||||||
|
controller.create_buffer(
|
||||||
|
buffer_type="chat_messages",
|
||||||
|
session_type="blueski",
|
||||||
|
buffer_title=title,
|
||||||
|
parent_tab=chats_position,
|
||||||
|
kwargs={"session": self.session, "convo_id": convo_id, "name": title},
|
||||||
|
start=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Navigate to the newly created buffer and announce it
|
||||||
|
new_index = controller.view.search(title, account_name)
|
||||||
|
if new_index is not None:
|
||||||
|
controller.view.change_buffer(new_index)
|
||||||
|
buffer_obj = controller.search_buffer(title, account_name)
|
||||||
|
if buffer_obj and hasattr(buffer_obj.buffer, "list"):
|
||||||
|
try:
|
||||||
|
count = buffer_obj.buffer.list.get_count()
|
||||||
|
if count > 0:
|
||||||
|
msg = _("{0}, {1} of {2}").format(title, buffer_obj.buffer.list.get_selected()+1, count)
|
||||||
|
else:
|
||||||
|
msg = _("{0}. Empty").format(title)
|
||||||
|
except Exception:
|
||||||
|
msg = _("{0}. Empty").format(title)
|
||||||
|
output.speak(msg, True)
|
||||||
|
|
||||||
|
def destroy_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ChatBuffer(BaseBuffer):
|
||||||
|
"""Buffer for displaying messages in a conversation, similar to Mastodon's ConversationBuffer."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["compose_func"] = "compose_chat_message"
|
||||||
|
super(ChatBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "chat_messages"
|
||||||
|
self.convo_id = kwargs.get("convo_id")
|
||||||
|
self.sound = "dm_received.ogg"
|
||||||
|
self._member_map_loaded = False
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.ChatMessagePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def _update_member_map(self):
|
||||||
|
"""Fetch conversation members to build a DID-to-name map for sender resolution."""
|
||||||
|
try:
|
||||||
|
convo = self.session.get_convo(self.convo_id)
|
||||||
|
if not convo:
|
||||||
|
return
|
||||||
|
member_map = {}
|
||||||
|
for m in getattr(convo, "members", []) or []:
|
||||||
|
did = getattr(m, "did", None)
|
||||||
|
if did:
|
||||||
|
name = getattr(m, "display_name", None) or getattr(m, "handle", None) or "unknown"
|
||||||
|
member_map[did] = name
|
||||||
|
self.session.db[self.name + "_members"] = member_map
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching conversation members for DID resolution")
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.convo_id:
|
||||||
|
return 0
|
||||||
|
if not self._member_map_loaded:
|
||||||
|
self._update_member_map()
|
||||||
|
self._member_map_loaded = True
|
||||||
|
count = self.get_max_items()
|
||||||
|
try:
|
||||||
|
res = self.session.get_convo_messages(self.convo_id, limit=count)
|
||||||
|
items = res.get("items", [])
|
||||||
|
items = list(reversed(items))
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error fetching chat messages")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
output.speak(_("This action is not supported for this buffer"), True)
|
||||||
|
|
||||||
|
def fav(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def unfav(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def can_share(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def destroy_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def url(self, *args, **kwargs):
|
||||||
|
"""Enter key opens reply dialog in chat."""
|
||||||
|
self.on_reply(None)
|
||||||
|
|
||||||
|
def on_reply(self, evt):
|
||||||
|
"""Open dialog to send a message in this conversation."""
|
||||||
|
if not self.convo_id:
|
||||||
|
output.speak(_("Cannot send message: no conversation selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get conversation title from buffer name or use generic
|
||||||
|
title = _("Send Message")
|
||||||
|
if self.name and self.name.startswith(_("Chat: ")):
|
||||||
|
title = self.name
|
||||||
|
caption = _("Write your message here")
|
||||||
|
|
||||||
|
post = blueski_messages.post(session=self.session, title=title, caption=caption, text="")
|
||||||
|
if post.message.ShowModal() == wx.ID_OK:
|
||||||
|
text, files, cw_text, langs = post.get_data()
|
||||||
|
if text:
|
||||||
|
def do_send():
|
||||||
|
try:
|
||||||
|
self.session.send_chat_message(self.convo_id, text)
|
||||||
|
wx.CallAfter(self.session.sound.play, "dm_sent.ogg")
|
||||||
|
wx.CallAfter(output.speak, _("Message sent."))
|
||||||
|
wx.CallAfter(self.start_stream, True, False)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error sending chat message")
|
||||||
|
wx.CallAfter(output.speak, _("Failed to send message."), True)
|
||||||
|
call_threaded(do_send)
|
||||||
|
if hasattr(post.message, "Destroy"):
|
||||||
|
post.message.Destroy()
|
||||||
|
|
||||||
|
def reply(self, *args, **kwargs):
|
||||||
|
"""Handle reply action (from menu or keyboard shortcut)."""
|
||||||
|
self.on_reply(None)
|
||||||
|
|
||||||
|
def send_message(self, *args, **kwargs):
|
||||||
|
"""Global shortcut for DM."""
|
||||||
|
self.on_reply(None)
|
||||||
|
|
||||||
|
def get_message(self):
|
||||||
|
"""Return a readable summary for the selected chat message."""
|
||||||
|
item = self.get_item()
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
composed = self.compose_function(
|
||||||
|
item, self.session.db, self.session.settings,
|
||||||
|
self.session.settings["general"].get("relative_times", False),
|
||||||
|
self.session.settings["general"].get("show_screen_names", False),
|
||||||
|
)
|
||||||
|
return " ".join(composed)
|
||||||
|
|
||||||
|
def get_formatted_message(self):
|
||||||
|
"""Return the text content of the selected chat message."""
|
||||||
|
item = self.get_item()
|
||||||
|
if item is None:
|
||||||
|
return None
|
||||||
|
composed = self.compose_function(
|
||||||
|
item, self.session.db, self.session.settings,
|
||||||
|
self.session.settings["general"].get("relative_times", False),
|
||||||
|
self.session.settings["general"].get("show_screen_names", False),
|
||||||
|
)
|
||||||
|
return composed[1]
|
||||||
|
|
||||||
|
def view_item(self, item=None):
|
||||||
|
"""View the selected chat message in a dialog."""
|
||||||
|
msg = self.get_formatted_message()
|
||||||
|
if not msg:
|
||||||
|
output.speak(_("No message selected."), True)
|
||||||
|
return
|
||||||
|
viewer = blueski_messages.text(title=_("Chat message"), text=msg)
|
||||||
|
viewer.message.ShowModal()
|
||||||
|
viewer.message.Destroy()
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
"""Allow removing this buffer."""
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
if force == False:
|
||||||
|
dlg = commonMessageDialogs.remove_buffer()
|
||||||
|
else:
|
||||||
|
dlg = widgetUtils.YES
|
||||||
|
if dlg == widgetUtils.YES:
|
||||||
|
if self.name in self.session.db:
|
||||||
|
self.session.db.pop(self.name)
|
||||||
|
return True
|
||||||
|
elif dlg == widgetUtils.NO:
|
||||||
|
return False
|
||||||
602
src/controller/buffers/blueski/timeline.py
Normal file
602
src/controller/buffers/blueski/timeline.py
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
import output
|
||||||
|
from .base import BaseBuffer
|
||||||
|
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.buffers.blueski.timeline")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeTimeline(BaseBuffer):
|
||||||
|
"""Discover feed buffer."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(HomeTimeline, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "home_timeline"
|
||||||
|
self.feed_uri = None
|
||||||
|
self.next_cursor = None
|
||||||
|
self.sound = "tweet_received.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not self.feed_uri:
|
||||||
|
self.feed_uri = self._resolve_discover_feed(api)
|
||||||
|
try:
|
||||||
|
if self.feed_uri:
|
||||||
|
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count})
|
||||||
|
else:
|
||||||
|
res = api.app.bsky.feed.get_timeline({"limit": count})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching home timeline: %s", e)
|
||||||
|
return 0
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
try:
|
||||||
|
if self.feed_uri:
|
||||||
|
res = api.app.bsky.feed.get_feed({"feed": self.feed_uri, "limit": count, "cursor": self.next_cursor})
|
||||||
|
else:
|
||||||
|
res = api.app.bsky.feed.get_timeline({"limit": count, "cursor": self.next_cursor})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more home timeline: %s", e)
|
||||||
|
|
||||||
|
def _resolve_discover_feed(self, api):
|
||||||
|
cached = self.session.db.get("discover_feed_uri")
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_suggested_feeds({"limit": 50})
|
||||||
|
for feed in getattr(res, "feeds", []):
|
||||||
|
dn = getattr(feed, "displayName", "") or getattr(feed, "display_name", "")
|
||||||
|
if "discover" in dn.lower():
|
||||||
|
uri = getattr(feed, "uri", "")
|
||||||
|
self.session.db["discover_feed_uri"] = uri
|
||||||
|
return uri
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FollowingTimeline(BaseBuffer):
|
||||||
|
"""Following-only timeline (reverse-chronological)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(FollowingTimeline, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "following_timeline"
|
||||||
|
self.next_cursor = None
|
||||||
|
self.sound = "tweet_received.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological"})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching following timeline: %s", e)
|
||||||
|
return 0
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_timeline({"limit": count, "algorithm": "reverse-chronological", "cursor": self.next_cursor})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more following timeline: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationBuffer(BaseBuffer):
|
||||||
|
"""Notifications buffer."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["compose_func"] = "compose_notification"
|
||||||
|
super(NotificationBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "notifications"
|
||||||
|
self.sound = "notification_received.ogg"
|
||||||
|
self.next_cursor = None
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def _hydrate_notifications(self, notifications):
|
||||||
|
"""Fetch subject post text for like/repost notifications."""
|
||||||
|
if not notifications:
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
# Collect URIs for likes/reposts that need subject post text
|
||||||
|
uris_to_fetch = []
|
||||||
|
for notif in notifications:
|
||||||
|
reason = g(notif, "reason", "")
|
||||||
|
if reason in ("like", "repost"):
|
||||||
|
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||||
|
if reason_subject and isinstance(reason_subject, str):
|
||||||
|
uris_to_fetch.append(reason_subject)
|
||||||
|
|
||||||
|
if not uris_to_fetch:
|
||||||
|
return notifications
|
||||||
|
|
||||||
|
# Fetch posts in batch
|
||||||
|
posts_map = {}
|
||||||
|
try:
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if api and uris_to_fetch:
|
||||||
|
# getPosts accepts up to 25 URIs at a time
|
||||||
|
for i in range(0, len(uris_to_fetch), 25):
|
||||||
|
batch = uris_to_fetch[i:i+25]
|
||||||
|
res = api.app.bsky.feed.get_posts({"uris": batch})
|
||||||
|
for post in getattr(res, "posts", []):
|
||||||
|
uri = g(post, "uri")
|
||||||
|
if uri:
|
||||||
|
record = g(post, "record", {})
|
||||||
|
text = g(record, "text", "")
|
||||||
|
posts_map[uri] = text
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching subject posts for notifications: %s", e)
|
||||||
|
|
||||||
|
# Attach subject post text to notifications
|
||||||
|
enriched = []
|
||||||
|
for notif in notifications:
|
||||||
|
reason = g(notif, "reason", "")
|
||||||
|
if reason in ("like", "repost"):
|
||||||
|
reason_subject = g(notif, "reasonSubject") or g(notif, "reason_subject")
|
||||||
|
if reason_subject and reason_subject in posts_map:
|
||||||
|
# Create a modified notification with subject post text
|
||||||
|
if isinstance(notif, dict):
|
||||||
|
notif = dict(notif)
|
||||||
|
notif["_subject_text"] = posts_map[reason_subject]
|
||||||
|
else:
|
||||||
|
# For ATProto model objects, add as attribute
|
||||||
|
try:
|
||||||
|
notif._subject_text = posts_map[reason_subject]
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
enriched.append(notif)
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||||
|
notifications = list(getattr(res, "notifications", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
if not notifications:
|
||||||
|
return 0
|
||||||
|
notifications = self._hydrate_notifications(notifications)
|
||||||
|
return self.process_items(notifications, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching notifications: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||||
|
notifications = list(getattr(res, "notifications", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
notifications = self._hydrate_notifications(notifications)
|
||||||
|
added = self.process_items(notifications, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more notifications: %s", e)
|
||||||
|
|
||||||
|
def add_new_item(self, notification):
|
||||||
|
notifications = self._hydrate_notifications([notification])
|
||||||
|
return self.process_items(notifications, play_sound=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(BaseBuffer):
|
||||||
|
"""Thread/conversation view."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(Conversation, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "conversation"
|
||||||
|
self.root_uri = kwargs.get("uri")
|
||||||
|
self.sound = "search_updated.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.root_uri:
|
||||||
|
return 0
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_post_thread({"uri": self.root_uri, "depth": 100, "parentHeight": 100})
|
||||||
|
thread = getattr(res, "thread", None)
|
||||||
|
if not thread:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default)
|
||||||
|
|
||||||
|
final_items = []
|
||||||
|
# Add ancestors
|
||||||
|
ancestors = []
|
||||||
|
parent = g(thread, "parent")
|
||||||
|
while parent:
|
||||||
|
ppost = g(parent, "post")
|
||||||
|
if ppost:
|
||||||
|
ancestors.insert(0, ppost)
|
||||||
|
parent = g(parent, "parent")
|
||||||
|
final_items.extend(ancestors)
|
||||||
|
|
||||||
|
# Traverse thread
|
||||||
|
def traverse(node):
|
||||||
|
if not node:
|
||||||
|
return
|
||||||
|
post = g(node, "post")
|
||||||
|
if post:
|
||||||
|
final_items.append(post)
|
||||||
|
for r in (g(node, "replies") or []):
|
||||||
|
traverse(r)
|
||||||
|
|
||||||
|
traverse(thread)
|
||||||
|
self.session.db[self.name] = []
|
||||||
|
self.buffer.list.clear()
|
||||||
|
# Don't use process_items() because it applies reverse logic.
|
||||||
|
# Conversations should always be chronological (oldest first).
|
||||||
|
return self._add_items_chronological(final_items, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching thread: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _add_items_chronological(self, items, play_sound=True):
|
||||||
|
"""Add items in chronological order (oldest first) without reverse logic."""
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
safe = True
|
||||||
|
relative_times = self.session.settings["general"].get("relative_times", False)
|
||||||
|
show_screen_names = self.session.settings["general"].get("show_screen_names", False)
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
self.session.db[self.name].append(item)
|
||||||
|
post = self.compose_function(item, self.session.db, self.session.settings,
|
||||||
|
relative_times=relative_times,
|
||||||
|
show_screen_names=show_screen_names,
|
||||||
|
safe=safe)
|
||||||
|
self.buffer.list.insert_item(False, *post)
|
||||||
|
|
||||||
|
# Select the root post (first item after ancestors, or just the first)
|
||||||
|
total = self.buffer.list.get_count()
|
||||||
|
if total > 0:
|
||||||
|
self.buffer.list.select_item(0)
|
||||||
|
|
||||||
|
if play_sound and self.sound and not self.session.settings["sound"]["session_mute"]:
|
||||||
|
self.session.sound.play(self.sound)
|
||||||
|
|
||||||
|
return len(items)
|
||||||
|
|
||||||
|
|
||||||
|
class LikesBuffer(BaseBuffer):
|
||||||
|
"""User's liked posts."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(LikesBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "likes"
|
||||||
|
self.next_cursor = None
|
||||||
|
self.sound = "favourite.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count})
|
||||||
|
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching likes: %s", e)
|
||||||
|
return 0
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_actor_likes({"actor": api.me.did, "limit": count, "cursor": self.next_cursor})
|
||||||
|
items = list(getattr(res, "feed", None) or getattr(res, "items", None) or [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more likes: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
class MentionsBuffer(BaseBuffer):
|
||||||
|
"""Mentions, replies and quotes."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["compose_func"] = "compose_notification"
|
||||||
|
super(MentionsBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "mentions"
|
||||||
|
self.sound = "mention_received.ogg"
|
||||||
|
self.next_cursor = None
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.NotificationPanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.notification.list_notifications({"limit": count})
|
||||||
|
notifications = getattr(res, "notifications", [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||||
|
if not mentions:
|
||||||
|
return 0
|
||||||
|
return self.process_items(mentions, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching mentions: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.notification.list_notifications({"limit": count, "cursor": self.next_cursor})
|
||||||
|
notifications = getattr(res, "notifications", [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
mentions = [n for n in notifications if getattr(n, "reason", "") in ("mention", "reply", "quote")]
|
||||||
|
if mentions:
|
||||||
|
added = self.process_items(mentions, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more mentions: %s", e)
|
||||||
|
|
||||||
|
def add_new_item(self, notification):
|
||||||
|
if getattr(notification, "reason", "") in ("mention", "reply", "quote"):
|
||||||
|
return self.process_items([notification], play_sound=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class SentBuffer(BaseBuffer):
|
||||||
|
"""User's sent posts."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(SentBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "sent"
|
||||||
|
self.next_cursor = None
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api or not api.me:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies"})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
if not items:
|
||||||
|
return 0
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching sent posts: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api or not api.me:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_author_feed({"actor": api.me.did, "limit": count, "filter": "posts_no_replies", "cursor": self.next_cursor})
|
||||||
|
items = list(getattr(res, "feed", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more sent posts: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
class UserTimeline(BaseBuffer):
|
||||||
|
"""Timeline for a specific user."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.actor = kwargs.get("actor")
|
||||||
|
self.handle = kwargs.get("handle")
|
||||||
|
super(UserTimeline, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "user_timeline"
|
||||||
|
self.next_cursor = None
|
||||||
|
self._resolved_actor = None
|
||||||
|
self.sound = "tweet_timeline.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.actor:
|
||||||
|
return 0
|
||||||
|
count = self.get_max_items()
|
||||||
|
actor = self.actor.strip().lstrip("@") if isinstance(self.actor, str) else self.actor
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
if isinstance(actor, str) and not actor.startswith("did:"):
|
||||||
|
profile = self.session.get_profile(actor)
|
||||||
|
if profile:
|
||||||
|
did = profile.get("did") if isinstance(profile, dict) else getattr(profile, "did", None)
|
||||||
|
if did:
|
||||||
|
actor = did
|
||||||
|
self._resolved_actor = actor
|
||||||
|
res = api.app.bsky.feed.get_author_feed({"actor": actor, "limit": count})
|
||||||
|
items = list(getattr(res, "feed", []) or [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching user timeline: %s", e)
|
||||||
|
return 0
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor or not self._resolved_actor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.get_author_feed({"actor": self._resolved_actor, "limit": count, "cursor": self.next_cursor})
|
||||||
|
items = list(getattr(res, "feed", []) or [])
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more user timeline: %s", e)
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if not force:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
import widgetUtils
|
||||||
|
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||||
|
return False
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
timelines = self.session.settings["other_buffers"].get("timelines") or []
|
||||||
|
if isinstance(timelines, str):
|
||||||
|
timelines = [t for t in timelines.split(",") if t]
|
||||||
|
for key in (self.actor or "", self.handle or ""):
|
||||||
|
if key in timelines:
|
||||||
|
timelines.remove(key)
|
||||||
|
self.session.settings["other_buffers"]["timelines"] = timelines
|
||||||
|
self.session.settings.write()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBuffer(BaseBuffer):
|
||||||
|
"""Search results buffer."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.search_query = kwargs.pop("query", "")
|
||||||
|
super(SearchBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "search"
|
||||||
|
self.next_cursor = None
|
||||||
|
self.sound = "search_updated.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.HomePanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.search_query:
|
||||||
|
return 0
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count})
|
||||||
|
posts = list(getattr(res, "posts", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
if not posts:
|
||||||
|
return 0
|
||||||
|
self.session.db[self.name] = []
|
||||||
|
self.buffer.list.clear()
|
||||||
|
return self.process_items(posts, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error searching posts: %s", e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.next_cursor or not self.search_query:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
api = self.session._ensure_client()
|
||||||
|
if not api:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
res = api.app.bsky.feed.search_posts({"q": self.search_query, "limit": count, "cursor": self.next_cursor})
|
||||||
|
posts = list(getattr(res, "posts", []))
|
||||||
|
self.next_cursor = getattr(res, "cursor", None)
|
||||||
|
added = self.process_items(posts, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % added, True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more search results: %s", e)
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if not force:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
import widgetUtils
|
||||||
|
if commonMessageDialogs.remove_buffer() != widgetUtils.YES:
|
||||||
|
return False
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
searches = self.session.settings["other_buffers"].get("searches") or []
|
||||||
|
if isinstance(searches, str):
|
||||||
|
searches = [s for s in searches.split(",") if s]
|
||||||
|
if self.search_query in searches:
|
||||||
|
searches.remove(self.search_query)
|
||||||
|
self.session.settings["other_buffers"]["searches"] = searches
|
||||||
|
self.session.settings.write()
|
||||||
|
return True
|
||||||
289
src/controller/buffers/blueski/user.py
Normal file
289
src/controller/buffers/blueski/user.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
import output
|
||||||
|
from .base import BaseBuffer
|
||||||
|
from wxUI.buffers.blueski import panels as BlueskiPanels
|
||||||
|
from sessions.blueski import compose
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.buffers.blueski.user")
|
||||||
|
|
||||||
|
class UserBuffer(BaseBuffer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# We need compose_user for this buffer
|
||||||
|
kwargs["compose_func"] = "compose_user"
|
||||||
|
super(UserBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "user"
|
||||||
|
self.next_cursor = None
|
||||||
|
self.sound = "new_event.ogg"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = BlueskiPanels.UserPanel(parent, name)
|
||||||
|
self.buffer.session = self.session
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
api_method = self.kwargs.get("api_method")
|
||||||
|
if not api_method: return 0
|
||||||
|
|
||||||
|
count = self.get_max_items()
|
||||||
|
actor = (
|
||||||
|
self.kwargs.get("actor")
|
||||||
|
or self.kwargs.get("did")
|
||||||
|
or self.kwargs.get("handle")
|
||||||
|
or self.kwargs.get("id")
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if api_method in ("get_followers", "get_follows"):
|
||||||
|
res = getattr(self.session, api_method)(actor=actor, limit=count)
|
||||||
|
else:
|
||||||
|
res = getattr(self.session, api_method)(limit=count)
|
||||||
|
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||||
|
self.next_cursor = res.get("cursor")
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching user list for %s: %s", self.name, e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
api_method = self.kwargs.get("api_method")
|
||||||
|
if not api_method or not self.next_cursor:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = self.get_max_items()
|
||||||
|
actor = (
|
||||||
|
self.kwargs.get("actor")
|
||||||
|
or self.kwargs.get("did")
|
||||||
|
or self.kwargs.get("handle")
|
||||||
|
or self.kwargs.get("id")
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if api_method in ("get_followers", "get_follows"):
|
||||||
|
res = getattr(self.session, api_method)(actor=actor, limit=count, cursor=self.next_cursor)
|
||||||
|
else:
|
||||||
|
res = getattr(self.session, api_method)(limit=count, cursor=self.next_cursor)
|
||||||
|
items = self._hydrate_profiles(res.get("items", []) or [])
|
||||||
|
self.next_cursor = res.get("cursor")
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more user list items for %s: %s", self.name, e)
|
||||||
|
|
||||||
|
def _hydrate_profiles(self, items):
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def g(obj, key, default=None):
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return obj.get(key, default)
|
||||||
|
return getattr(obj, key, default)
|
||||||
|
|
||||||
|
def resolve_profile(obj):
|
||||||
|
if g(obj, "handle") or g(obj, "did"):
|
||||||
|
return obj
|
||||||
|
for key in ("subject", "actor", "profile", "user"):
|
||||||
|
nested = g(obj, key)
|
||||||
|
if nested and (g(nested, "handle") or g(nested, "did")):
|
||||||
|
return nested
|
||||||
|
return obj
|
||||||
|
|
||||||
|
actors = []
|
||||||
|
for item in items:
|
||||||
|
profile = resolve_profile(item)
|
||||||
|
did = g(profile, "did")
|
||||||
|
handle = g(profile, "handle")
|
||||||
|
if did:
|
||||||
|
actors.append(did)
|
||||||
|
elif handle:
|
||||||
|
actors.append(handle)
|
||||||
|
|
||||||
|
if not actors:
|
||||||
|
return items
|
||||||
|
|
||||||
|
profiles = []
|
||||||
|
if actors and hasattr(self.session, "get_profiles"):
|
||||||
|
try:
|
||||||
|
res = self.session.get_profiles(actors)
|
||||||
|
profiles = res.get("items", []) or []
|
||||||
|
except Exception:
|
||||||
|
profiles = []
|
||||||
|
# If batch profiles lack counts, hydrate with detailed profiles.
|
||||||
|
if hasattr(self.session, "get_profile"):
|
||||||
|
def counts_missing(profile_obj):
|
||||||
|
p1 = g(profile_obj, "followersCount") or g(profile_obj, "followers_count")
|
||||||
|
p2 = g(profile_obj, "followsCount") or g(profile_obj, "follows_count")
|
||||||
|
p3 = g(profile_obj, "postsCount") or g(profile_obj, "posts_count")
|
||||||
|
if p1 is None and p2 is None and p3 is None:
|
||||||
|
return True
|
||||||
|
return (p1 or 0) == 0 and (p2 or 0) == 0 and (p3 or 0) == 0
|
||||||
|
|
||||||
|
if not profiles:
|
||||||
|
for actor in actors:
|
||||||
|
try:
|
||||||
|
p = self.session.get_profile(actor)
|
||||||
|
if p:
|
||||||
|
profiles.append(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
for idx, p in enumerate(profiles):
|
||||||
|
if counts_missing(p):
|
||||||
|
did = g(p, "did") or g(p, "handle")
|
||||||
|
if not did:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
detailed = self.session.get_profile(did)
|
||||||
|
if detailed:
|
||||||
|
profiles[idx] = detailed
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
profile_map = {}
|
||||||
|
for p in profiles:
|
||||||
|
did = g(p, "did")
|
||||||
|
handle = g(p, "handle")
|
||||||
|
if did:
|
||||||
|
profile_map[did] = p
|
||||||
|
if handle and handle not in profile_map:
|
||||||
|
profile_map[handle] = p
|
||||||
|
|
||||||
|
def needs_replace(item, profile):
|
||||||
|
if profile is None:
|
||||||
|
return False
|
||||||
|
base = resolve_profile(item)
|
||||||
|
f1 = g(base, "followersCount") or g(base, "followers_count")
|
||||||
|
f2 = g(base, "followsCount") or g(base, "follows_count")
|
||||||
|
f3 = g(base, "postsCount") or g(base, "posts_count")
|
||||||
|
p1 = g(profile, "followersCount") or g(profile, "followers_count")
|
||||||
|
p2 = g(profile, "followsCount") or g(profile, "follows_count")
|
||||||
|
p3 = g(profile, "postsCount") or g(profile, "posts_count")
|
||||||
|
if f1 is None and f2 is None and f3 is None:
|
||||||
|
return True
|
||||||
|
if (f1 or 0) == 0 and (f2 or 0) == 0 and (f3 or 0) == 0:
|
||||||
|
return (p1 or 0) != 0 or (p2 or 0) != 0 or (p3 or 0) != 0
|
||||||
|
return False
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for item in items:
|
||||||
|
base = resolve_profile(item)
|
||||||
|
did = g(base, "did")
|
||||||
|
handle = g(base, "handle")
|
||||||
|
profile = profile_map.get(did) or profile_map.get(handle)
|
||||||
|
if needs_replace(item, profile):
|
||||||
|
enriched.append(profile)
|
||||||
|
else:
|
||||||
|
enriched.append(item)
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
class FollowersBuffer(UserBuffer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["api_method"] = "get_followers"
|
||||||
|
super(FollowersBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.sound = "update_followers.ogg"
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if not force:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
import widgetUtils
|
||||||
|
dlg = commonMessageDialogs.remove_buffer()
|
||||||
|
if dlg != widgetUtils.YES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||||
|
timelines = self.session.settings["other_buffers"].get("followers_timelines") or []
|
||||||
|
if isinstance(timelines, str):
|
||||||
|
timelines = [t for t in timelines.split(",") if t]
|
||||||
|
if key in timelines:
|
||||||
|
timelines.remove(key)
|
||||||
|
self.session.settings["other_buffers"]["followers_timelines"] = timelines
|
||||||
|
self.session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error updating Bluesky followers timelines settings: %s", e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
class FollowingBuffer(UserBuffer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["api_method"] = "get_follows"
|
||||||
|
super(FollowingBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.sound = "update_followers.ogg"
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if not force:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
import widgetUtils
|
||||||
|
dlg = commonMessageDialogs.remove_buffer()
|
||||||
|
if dlg != widgetUtils.YES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
key = self.kwargs.get("actor") or self.kwargs.get("handle") or self.kwargs.get("id")
|
||||||
|
timelines = self.session.settings["other_buffers"].get("following_timelines") or []
|
||||||
|
if isinstance(timelines, str):
|
||||||
|
timelines = [t for t in timelines.split(",") if t]
|
||||||
|
if key in timelines:
|
||||||
|
timelines.remove(key)
|
||||||
|
self.session.settings["other_buffers"]["following_timelines"] = timelines
|
||||||
|
self.session.settings.write()
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error updating Bluesky following timelines settings: %s", e)
|
||||||
|
return True
|
||||||
|
|
||||||
|
class BlocksBuffer(UserBuffer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs["api_method"] = "get_blocks"
|
||||||
|
super(BlocksBuffer, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PostUserListBuffer(UserBuffer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.post_uri = kwargs.get("post_uri")
|
||||||
|
self.api_method = kwargs.get("api_method")
|
||||||
|
super(PostUserListBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "post_user_list"
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True):
|
||||||
|
if not self.api_method or not self.post_uri:
|
||||||
|
return 0
|
||||||
|
count = self.get_max_items()
|
||||||
|
try:
|
||||||
|
res = getattr(self.session, self.api_method)(self.post_uri, limit=count)
|
||||||
|
items = res.get("items", [])
|
||||||
|
self.next_cursor = res.get("cursor")
|
||||||
|
return self.process_items(items, play_sound)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching post user list for %s: %s", self.name, e)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
if not self.api_method or not self.post_uri or not self.next_cursor:
|
||||||
|
return
|
||||||
|
count = self.get_max_items()
|
||||||
|
try:
|
||||||
|
res = getattr(self.session, self.api_method)(self.post_uri, limit=count, cursor=self.next_cursor)
|
||||||
|
items = res.get("items", [])
|
||||||
|
self.next_cursor = res.get("cursor")
|
||||||
|
added = self.process_items(items, play_sound=False)
|
||||||
|
if added:
|
||||||
|
output.speak(_(u"%s items retrieved") % (str(added)), True)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Error fetching more post user list items for %s: %s", self.name, e)
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if not force:
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
import widgetUtils
|
||||||
|
dlg = commonMessageDialogs.remove_buffer()
|
||||||
|
if dlg != widgetUtils.YES:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.session.db.pop(self.name, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
@@ -5,3 +5,5 @@ from .conversations import ConversationBuffer, ConversationListBuffer
|
|||||||
from .users import UserBuffer
|
from .users import UserBuffer
|
||||||
from .notifications import NotificationsBuffer
|
from .notifications import NotificationsBuffer
|
||||||
from .search import SearchBuffer
|
from .search import SearchBuffer
|
||||||
|
from .community import CommunityBuffer
|
||||||
|
from .announcements import AnnouncementsBuffer
|
||||||
165
src/controller/buffers/mastodon/announcements.py
Normal file
165
src/controller/buffers/mastodon/announcements.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import arrow
|
||||||
|
import widgetUtils
|
||||||
|
import wx
|
||||||
|
import output
|
||||||
|
import languageHandler
|
||||||
|
import config
|
||||||
|
from pubsub import pub
|
||||||
|
from controller.buffers.mastodon.base import BaseBuffer
|
||||||
|
from sessions.mastodon import compose, templates
|
||||||
|
from wxUI import buffers
|
||||||
|
from wxUI.dialogs.mastodon import menus
|
||||||
|
from mysc.thread_utils import call_threaded
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.buffers.mastodon.announcements")
|
||||||
|
|
||||||
|
class AnnouncementsBuffer(BaseBuffer):
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
# We enforce compose_func="compose_announcement"
|
||||||
|
kwargs["compose_func"] = "compose_announcement"
|
||||||
|
super(AnnouncementsBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.type = "announcementsBuffer"
|
||||||
|
|
||||||
|
def create_buffer(self, parent, name):
|
||||||
|
self.buffer = buffers.mastodon.announcementsPanel(parent, name)
|
||||||
|
|
||||||
|
def get_buffer_name(self):
|
||||||
|
return _("Announcements")
|
||||||
|
|
||||||
|
def bind_events(self):
|
||||||
|
self.buffer.set_focus_function(self.onFocus)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||||
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.dismiss_announcement, self.buffer.dismiss)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_ITEM_RIGHT_CLICK, self.show_menu)
|
||||||
|
widgetUtils.connect_event(self.buffer.list.list, wx.EVT_LIST_KEY_DOWN, self.show_menu_by_key)
|
||||||
|
|
||||||
|
def dismiss_announcement(self, event=None, item=None, *args, **kwargs):
|
||||||
|
index = self.buffer.list.get_selected()
|
||||||
|
if index == -1: return
|
||||||
|
item = self.session.db[self.name][index]
|
||||||
|
|
||||||
|
# Optimistic UI update or wait for API? Let's wait for API to be safe, but run threaded.
|
||||||
|
# We need a custom call because 'announcements_dismiss' returns None on success usually.
|
||||||
|
def _do_dismiss():
|
||||||
|
try:
|
||||||
|
self.session.api_call(call_name="announcements_dismiss", id=str(item.id))
|
||||||
|
# If success, update UI in main thread
|
||||||
|
wx.CallAfter(self._on_dismiss_success, index)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error dismissing announcement")
|
||||||
|
self.session.sound.play("error.ogg")
|
||||||
|
|
||||||
|
call_threaded(_do_dismiss)
|
||||||
|
|
||||||
|
def _on_dismiss_success(self, index):
|
||||||
|
if index < len(self.session.db[self.name]):
|
||||||
|
self.session.db[self.name].pop(index)
|
||||||
|
self.buffer.list.remove_item(index)
|
||||||
|
output.speak(_("Announcement dismissed."))
|
||||||
|
|
||||||
|
def show_menu(self, ev, pos=0, *args, **kwargs):
|
||||||
|
if self.buffer.list.get_count() == 0:
|
||||||
|
return
|
||||||
|
# Create a simple menu
|
||||||
|
menu = wx.Menu()
|
||||||
|
dismiss_item = menu.Append(wx.ID_ANY, _("Dismiss"))
|
||||||
|
copy_item = menu.Append(wx.ID_ANY, _("Copy text"))
|
||||||
|
|
||||||
|
self.buffer.Bind(wx.EVT_MENU, self.dismiss_announcement, dismiss_item)
|
||||||
|
self.buffer.Bind(wx.EVT_MENU, self.copy, copy_item)
|
||||||
|
|
||||||
|
if pos != 0:
|
||||||
|
self.buffer.PopupMenu(menu, pos)
|
||||||
|
else:
|
||||||
|
self.buffer.PopupMenu(menu, self.buffer.list.list.GetPosition())
|
||||||
|
|
||||||
|
def url(self, *args, **kwargs):
|
||||||
|
self.dismiss_announcement()
|
||||||
|
|
||||||
|
def get_more_items(self): output.speak(_("This buffer does not support loading more items."), True)
|
||||||
|
|
||||||
|
# Disable social interactions not applicable to announcements
|
||||||
|
def reply(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def share_item(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def toggle_favorite(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_to_favorites(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def remove_from_favorites(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def toggle_bookmark(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mute_conversation(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def vote(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_message(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def user_details(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def view_item(self, *args, **kwargs):
|
||||||
|
# We could implement a specific viewer for announcements if needed,
|
||||||
|
# but the default one expects a status object structure.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def copy(self, event=None):
|
||||||
|
item = self.get_item()
|
||||||
|
if item:
|
||||||
|
pub.sendMessage("execute-action", action="copy_to_clipboard")
|
||||||
|
|
||||||
|
def onFocus(self, *args, **kwargs):
|
||||||
|
# Similar logic to BaseBuffer but adapted if needed.
|
||||||
|
# BaseBuffer.onFocus handles reading long posts.
|
||||||
|
if config.app["app-settings"]["read_long_posts_in_gui"] == True and self.buffer.list.list.HasFocus():
|
||||||
|
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
|
||||||
|
|
||||||
|
def get_message(self):
|
||||||
|
# Override to use announcement template
|
||||||
|
announcement = self.get_item()
|
||||||
|
if announcement == None:
|
||||||
|
return
|
||||||
|
template = self.session.settings.get("templates", {}).get("announcement", templates.announcement_default_template)
|
||||||
|
t = templates.render_announcement(announcement, template, self.session.settings, relative_times=self.session.settings["general"]["relative_times"], offset_hours=self.session.db["utc_offset"])
|
||||||
|
return t
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||||
|
current_time = time.time()
|
||||||
|
if self.execution_time == 0 or current_time-self.execution_time >= 300 or mandatory==True:
|
||||||
|
self.execution_time = current_time
|
||||||
|
log.debug("Starting stream for announcements buffer")
|
||||||
|
try:
|
||||||
|
# The announcements API does not accept min_id or limit parameters
|
||||||
|
results = self.session.api.announcements()
|
||||||
|
# Reverse the list so order_buffer processes them according to user preference
|
||||||
|
results.reverse()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error retrieving announcements: %s" % (str(e)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# order_buffer handles duplication filtering by ID internally
|
||||||
|
number_of_items = self.session.order_buffer(self.name, results)
|
||||||
|
log.debug("Number of new announcements retrieved: %d" % (number_of_items,))
|
||||||
|
|
||||||
|
self.put_items_on_list(number_of_items)
|
||||||
|
|
||||||
|
if number_of_items > 0 and play_sound == True and self.sound != None and self.session.settings["sound"]["session_mute"] == False:
|
||||||
|
self.session.sound.play(self.sound)
|
||||||
|
|
||||||
|
return number_of_items
|
||||||
|
return 0
|
||||||
@@ -40,9 +40,31 @@ class BaseBuffer(base.Buffer):
|
|||||||
self.buffer.account = account
|
self.buffer.account = account
|
||||||
self.bind_events()
|
self.bind_events()
|
||||||
self.sound = sound
|
self.sound = sound
|
||||||
|
pub.subscribe(self.on_mute_cleanup, "mastodon.mute_cleanup")
|
||||||
if "-timeline" in self.name or "-followers" in self.name or "-following" in self.name or "searchterm" in self.name:
|
if "-timeline" in self.name or "-followers" in self.name or "-following" in self.name or "searchterm" in self.name:
|
||||||
self.finished_timeline = False
|
self.finished_timeline = False
|
||||||
|
|
||||||
|
def on_mute_cleanup(self, conversation_id, session_name):
|
||||||
|
if self.name != "home_timeline":
|
||||||
|
return
|
||||||
|
if session_name != self.session.get_name():
|
||||||
|
return
|
||||||
|
items_to_remove = []
|
||||||
|
for index, item in enumerate(self.session.db[self.name]):
|
||||||
|
c_id = None
|
||||||
|
if hasattr(item, "conversation_id"):
|
||||||
|
c_id = item.conversation_id
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
c_id = item.get("conversation_id")
|
||||||
|
|
||||||
|
if c_id == conversation_id:
|
||||||
|
items_to_remove.append(index)
|
||||||
|
|
||||||
|
items_to_remove.sort(reverse=True)
|
||||||
|
for index in items_to_remove:
|
||||||
|
self.session.db[self.name].pop(index)
|
||||||
|
self.buffer.list.remove_item(index)
|
||||||
|
|
||||||
def create_buffer(self, parent, name):
|
def create_buffer(self, parent, name):
|
||||||
self.buffer = buffers.mastodon.basePanel(parent, name)
|
self.buffer = buffers.mastodon.basePanel(parent, name)
|
||||||
|
|
||||||
@@ -70,7 +92,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, posts=post_data, visibility=post.get_visibility(), **kwargs)
|
call_threaded(self.session.send_post, posts=post_data, visibility=post.get_visibility(), language=post.get_language(), **kwargs)
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|
||||||
@@ -104,17 +126,34 @@ class BaseBuffer(base.Buffer):
|
|||||||
min_id = None
|
min_id = None
|
||||||
# toDo: Implement reverse timelines properly here.
|
# toDo: Implement reverse timelines properly here.
|
||||||
if (self.name != "favorites" and self.name != "bookmarks") and self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
if (self.name != "favorites" and self.name != "bookmarks") and self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||||
if self.session.settings["general"]["reverse_timelines"]:
|
# We use the maximum ID present in the buffer to ensure we only request posts
|
||||||
min_id = self.session.db[self.name][0].id
|
# that are newer than our most recent chronological post.
|
||||||
|
# This prevents old pinned posts from pulling in hundreds of previous statuses.
|
||||||
|
min_id = max(item.id for item in self.session.db[self.name])
|
||||||
|
# loads pinned posts from user accounts.
|
||||||
|
# Load those posts only when there are no items previously loaded.
|
||||||
|
if "-timeline" in self.name and "account_statuses" in self.function and len(self.session.db.get(self.name, [])) == 0:
|
||||||
|
pinned_posts = self.session.api.account_statuses(pinned=True, limit=count, *self.args, **self.kwargs)
|
||||||
|
for p in pinned_posts:
|
||||||
|
p["pinned"] = True
|
||||||
|
pinned_posts.reverse()
|
||||||
else:
|
else:
|
||||||
min_id = self.session.db[self.name][-1].id
|
pinned_posts = None
|
||||||
try:
|
try:
|
||||||
results = getattr(self.session.api, self.function)(min_id=min_id, limit=count, *self.args, **self.kwargs)
|
results = getattr(self.session.api, self.function)(min_id=min_id, limit=count, *self.args, **self.kwargs)
|
||||||
results.reverse()
|
results.reverse()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception("Error %s" % (str(e)))
|
log.exception("Error %s" % (str(e)))
|
||||||
return
|
return
|
||||||
|
if self.session.settings["general"]["reverse_timelines"]:
|
||||||
|
if pinned_posts != None and len(pinned_posts) > 0:
|
||||||
|
amount_of_pinned_posts = self.session.order_buffer(self.name, pinned_posts)
|
||||||
number_of_items = self.session.order_buffer(self.name, results)
|
number_of_items = self.session.order_buffer(self.name, results)
|
||||||
|
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||||
|
if pinned_posts != None and len(pinned_posts) > 0:
|
||||||
|
amount_of_pinned_posts = self.session.order_buffer(self.name, pinned_posts)
|
||||||
|
if pinned_posts != None and len(pinned_posts) > 0:
|
||||||
|
number_of_items = amount_of_pinned_posts+number_of_items
|
||||||
log.debug("Number of items retrieved: %d" % (number_of_items,))
|
log.debug("Number of items retrieved: %d" % (number_of_items,))
|
||||||
if hasattr(self, "finished_timeline") and self.finished_timeline == False:
|
if hasattr(self, "finished_timeline") and self.finished_timeline == False:
|
||||||
if "-timeline" in self.name:
|
if "-timeline" in self.name:
|
||||||
@@ -145,10 +184,17 @@ class BaseBuffer(base.Buffer):
|
|||||||
|
|
||||||
def get_more_items(self):
|
def get_more_items(self):
|
||||||
elements = []
|
elements = []
|
||||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
if len(self.session.db[self.name]) == 0:
|
||||||
max_id = self.session.db[self.name][0].id
|
return
|
||||||
|
# We use the minimum ID in the buffer to correctly request the next page of older items.
|
||||||
|
# This prevents old pinned posts from causing us to skip chronological posts.
|
||||||
|
# We try to exclude pinned posts from this calculation as they are usually outliers at the top.
|
||||||
|
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
|
||||||
|
if unpinned_ids:
|
||||||
|
max_id = min(unpinned_ids)
|
||||||
else:
|
else:
|
||||||
max_id = self.session.db[self.name][-1].id
|
max_id = min(item.id for item in self.session.db[self.name])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
|
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -157,6 +203,9 @@ class BaseBuffer(base.Buffer):
|
|||||||
items_db = self.session.db[self.name]
|
items_db = self.session.db[self.name]
|
||||||
for i in items:
|
for i in items:
|
||||||
if utils.find_item(i, self.session.db[self.name]) == None:
|
if utils.find_item(i, self.session.db[self.name]) == None:
|
||||||
|
filter_status = utils.evaluate_filters(post=i, current_context=utils.get_current_context(self.name))
|
||||||
|
if filter_status == "hide":
|
||||||
|
continue
|
||||||
elements.append(i)
|
elements.append(i)
|
||||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||||
items_db.insert(0, i)
|
items_db.insert(0, i)
|
||||||
@@ -262,13 +311,22 @@ class BaseBuffer(base.Buffer):
|
|||||||
return
|
return
|
||||||
menu = menus.base()
|
menu = menus.base()
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
||||||
|
# Enable/disable edit based on whether the post belongs to the user
|
||||||
|
item = self.get_item()
|
||||||
|
if item and item.account.id == self.session.db["user_id"] and item.reblog == None:
|
||||||
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
|
||||||
|
else:
|
||||||
|
menu.edit.Enable(False)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||||
if self.can_share() == True:
|
if self.can_share() == True:
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||||
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.quote, menuitem=menu.quote)
|
||||||
else:
|
else:
|
||||||
menu.boost.Enable(False)
|
menu.boost.Enable(False)
|
||||||
|
menu.quote.Enable(False)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.fav, menuitem=menu.fav)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.fav, menuitem=menu.fav)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.unfav, menuitem=menu.unfav)
|
||||||
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.mute_conversation, menuitem=menu.mute)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.url_, menuitem=menu.openUrl)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.audio, menuitem=menu.play)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.audio, menuitem=menu.play)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.view, menuitem=menu.view)
|
||||||
@@ -320,7 +378,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def reply(self, item=None, *args, **kwargs):
|
def reply(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
visibility = item.visibility
|
visibility = item.visibility
|
||||||
@@ -335,16 +393,19 @@ class BaseBuffer(base.Buffer):
|
|||||||
visibility = "unlisted"
|
visibility = "unlisted"
|
||||||
if item.reblog != None:
|
if item.reblog != None:
|
||||||
users = ["@{} ".format(user.acct) for user in item.reblog.mentions if user.id != self.session.db["user_id"]]
|
users = ["@{} ".format(user.acct) for user in item.reblog.mentions if user.id != self.session.db["user_id"]]
|
||||||
|
language = item.reblog.language
|
||||||
if item.reblog.account.acct != item.account.acct and "@{} ".format(item.reblog.account.acct) not in users:
|
if item.reblog.account.acct != item.account.acct and "@{} ".format(item.reblog.account.acct) not in users:
|
||||||
users.append("@{} ".format(item.reblog.account.acct))
|
users.append("@{} ".format(item.reblog.account.acct))
|
||||||
else:
|
else:
|
||||||
users = ["@{} ".format(user.acct) for user in item.mentions if user.id != self.session.db["user_id"]]
|
users = ["@{} ".format(user.acct) for user in item.mentions if user.id != self.session.db["user_id"]]
|
||||||
|
language = item.language
|
||||||
if "@{} ".format(item.account.acct) not in users and item.account.id != self.session.db["user_id"]:
|
if "@{} ".format(item.account.acct) not in users and item.account.id != self.session.db["user_id"]:
|
||||||
users.insert(0, "@{} ".format(item.account.acct))
|
users.insert(0, "@{} ".format(item.account.acct))
|
||||||
users_str = "".join(users)
|
users_str = "".join(users)
|
||||||
post = messages.post(session=self.session, title=title, caption=caption, text=users_str)
|
post = messages.post(session=self.session, title=title, caption=caption, text=users_str)
|
||||||
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
|
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
|
||||||
post.message.visibility.SetSelection(visibility_settings.get(visibility))
|
post.message.visibility.SetSelection(visibility_settings.get(visibility))
|
||||||
|
post.set_language(language)
|
||||||
# Respect content warning settings.
|
# Respect content warning settings.
|
||||||
if item.sensitive:
|
if item.sensitive:
|
||||||
post.message.sensitive.SetValue(item.sensitive)
|
post.message.sensitive.SetValue(item.sensitive)
|
||||||
@@ -353,11 +414,11 @@ class BaseBuffer(base.Buffer):
|
|||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, reply_to=item.id, posts=post_data, visibility=post.get_visibility())
|
call_threaded(self.session.send_post, reply_to=item.id, posts=post_data, visibility=post.get_visibility(), language=post.get_language())
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|
||||||
def send_message(self, item=None, *args, **kwargs):
|
def send_message(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
title = _("Conversation with {}").format(item.account.username)
|
title = _("Conversation with {}").format(item.account.username)
|
||||||
@@ -380,11 +441,11 @@ class BaseBuffer(base.Buffer):
|
|||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, posts=post_data, visibility="direct", reply_to=item.id)
|
call_threaded(self.session.send_post, posts=post_data, visibility="direct", reply_to=item.id, language=post.get_language())
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|
||||||
def share_item(self, item=None, *args, **kwargs):
|
def share_item(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
if self.can_share(item=item) == False:
|
if self.can_share(item=item) == False:
|
||||||
@@ -392,11 +453,30 @@ class BaseBuffer(base.Buffer):
|
|||||||
id = item.id
|
id = item.id
|
||||||
if self.session.settings["general"]["boost_mode"] == "ask":
|
if self.session.settings["general"]["boost_mode"] == "ask":
|
||||||
answer = mastodon_dialogs.boost_question()
|
answer = mastodon_dialogs.boost_question()
|
||||||
if answer == True:
|
if answer == 1:
|
||||||
self._direct_boost(id)
|
self._direct_boost(id)
|
||||||
|
elif answer == 2:
|
||||||
|
self.quote(item=item)
|
||||||
else:
|
else:
|
||||||
self._direct_boost(id)
|
self._direct_boost(id)
|
||||||
|
|
||||||
|
def quote(self, event=None, item=None, *args, **kwargs):
|
||||||
|
if item == None:
|
||||||
|
item = self.get_item()
|
||||||
|
if self.can_share(item=item) == False:
|
||||||
|
return output.speak(_("This action is not supported on conversations."))
|
||||||
|
|
||||||
|
title = _("Quote post")
|
||||||
|
caption = _("Write your comment here")
|
||||||
|
post = messages.post(session=self.session, title=title, caption=caption)
|
||||||
|
|
||||||
|
response = post.message.ShowModal()
|
||||||
|
if response == wx.ID_OK:
|
||||||
|
post_data = post.get_data()
|
||||||
|
call_threaded(self.session.send_post, quote_id=item.id, posts=post_data, visibility=post.get_visibility(), language=post.get_language(), **kwargs)
|
||||||
|
if hasattr(post.message, "destroy"):
|
||||||
|
post.message.destroy()
|
||||||
|
|
||||||
def _direct_boost(self, id):
|
def _direct_boost(self, id):
|
||||||
item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id)
|
item = self.session.api_call(call_name="status_reblog", _sound="retweet_send.ogg", id=id)
|
||||||
|
|
||||||
@@ -406,6 +486,8 @@ class BaseBuffer(base.Buffer):
|
|||||||
original_date = arrow.get(self.session.db[self.name][self.buffer.list.get_selected()].created_at)
|
original_date = arrow.get(self.session.db[self.name][self.buffer.list.get_selected()].created_at)
|
||||||
ts = original_date.humanize(locale=languageHandler.getLanguage())
|
ts = original_date.humanize(locale=languageHandler.getLanguage())
|
||||||
self.buffer.list.list.SetItem(self.buffer.list.get_selected(), 2, ts)
|
self.buffer.list.list.SetItem(self.buffer.list.get_selected(), 2, ts)
|
||||||
|
if config.app["app-settings"]["read_long_posts_in_gui"] == True and self.buffer.list.list.HasFocus():
|
||||||
|
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
|
||||||
if self.session.settings['sound']['indicate_audio'] and utils.is_audio_or_video(post):
|
if self.session.settings['sound']['indicate_audio'] and utils.is_audio_or_video(post):
|
||||||
self.session.sound.play("audio.ogg")
|
self.session.sound.play("audio.ogg")
|
||||||
if self.session.settings['sound']['indicate_img'] and utils.is_image(post):
|
if self.session.settings['sound']['indicate_img'] and utils.is_image(post):
|
||||||
@@ -414,7 +496,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
pub.sendMessage("toggleShare", shareable=can_share)
|
pub.sendMessage("toggleShare", shareable=can_share)
|
||||||
self.buffer.boost.Enable(can_share)
|
self.buffer.boost.Enable(can_share)
|
||||||
|
|
||||||
def audio(self, item=None, *args, **kwargs):
|
def audio(self, event=None, item=None, *args, **kwargs):
|
||||||
if sound.URLPlayer.player.is_playing():
|
if sound.URLPlayer.player.is_playing():
|
||||||
return sound.URLPlayer.stop_audio()
|
return sound.URLPlayer.stop_audio()
|
||||||
if item == None:
|
if item == None:
|
||||||
@@ -478,6 +560,49 @@ class BaseBuffer(base.Buffer):
|
|||||||
log.exception("")
|
log.exception("")
|
||||||
self.session.db[self.name] = items
|
self.session.db[self.name] = items
|
||||||
|
|
||||||
|
def edit_status(self, event=None, item=None, *args, **kwargs):
|
||||||
|
if item == None:
|
||||||
|
item = self.get_item()
|
||||||
|
# Check if the post belongs to the current user
|
||||||
|
if item.account.id != self.session.db["user_id"] or item.reblog != None:
|
||||||
|
output.speak(_("You can only edit your own posts."))
|
||||||
|
return
|
||||||
|
# Check if post has a poll with votes - warn user before proceeding
|
||||||
|
if hasattr(item, 'poll') and item.poll is not None:
|
||||||
|
votes_count = item.poll.votes_count if hasattr(item.poll, 'votes_count') else 0
|
||||||
|
if votes_count > 0:
|
||||||
|
# Show confirmation dialog
|
||||||
|
warning_title = _("Warning: Poll with votes")
|
||||||
|
warning_message = _("This post contains a poll with {votes} votes.\n\n"
|
||||||
|
"According to Mastodon's API, editing this post will reset ALL votes to zero, "
|
||||||
|
"even if you don't modify the poll itself.\n\n"
|
||||||
|
"Do you want to continue editing?").format(votes=votes_count)
|
||||||
|
dialog = wx.MessageDialog(self.buffer, warning_message, warning_title,
|
||||||
|
wx.YES_NO | wx.NO_DEFAULT | wx.ICON_WARNING)
|
||||||
|
result = dialog.ShowModal()
|
||||||
|
dialog.Destroy()
|
||||||
|
if result != wx.ID_YES:
|
||||||
|
output.speak(_("Edit cancelled"))
|
||||||
|
return
|
||||||
|
# Log item info for debugging
|
||||||
|
log.debug("Editing status: id={}, has_media_attachments={}, media_count={}".format(
|
||||||
|
item.id,
|
||||||
|
hasattr(item, 'media_attachments'),
|
||||||
|
len(item.media_attachments) if hasattr(item, 'media_attachments') else 0
|
||||||
|
))
|
||||||
|
# Create edit dialog with existing post data
|
||||||
|
title = _("Edit post")
|
||||||
|
caption = _("Edit your post here")
|
||||||
|
post = messages.editPost(session=self.session, item=item, title=title, caption=caption)
|
||||||
|
response = post.message.ShowModal()
|
||||||
|
if response == wx.ID_OK:
|
||||||
|
post_data = post.get_data()
|
||||||
|
# Call edit_post method in session
|
||||||
|
# Note: visibility and language cannot be changed when editing per Mastodon API
|
||||||
|
call_threaded(self.session.edit_post, post_id=post.post_id, posts=post_data)
|
||||||
|
if hasattr(post.message, "destroy"):
|
||||||
|
post.message.destroy()
|
||||||
|
|
||||||
def user_details(self):
|
def user_details(self):
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
pass
|
pass
|
||||||
@@ -489,7 +614,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
return item.reblog.url
|
return item.reblog.url
|
||||||
return item.url
|
return item.url
|
||||||
|
|
||||||
def open_in_browser(self, item=None, *args, **kwargs):
|
def open_in_browser(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
url = self.get_item_url(item=item)
|
url = self.get_item_url(item=item)
|
||||||
@@ -510,7 +635,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
item = item.reblog
|
item = item.reblog
|
||||||
call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id)
|
call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id)
|
||||||
|
|
||||||
def toggle_favorite(self, item=None, *args, **kwargs):
|
def toggle_favorite(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
if item.reblog != None:
|
if item.reblog != None:
|
||||||
@@ -525,7 +650,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
else:
|
else:
|
||||||
call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id)
|
call_threaded(self.session.api_call, call_name="status_unfavourite", preexec_message=_("Removing from favorites..."), _sound="favourite.ogg", id=item.id)
|
||||||
|
|
||||||
def toggle_bookmark(self, item=None, *args, **kwargs):
|
def toggle_bookmark(self, event=None, item=None, *args, **kwargs):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
if item.reblog != None:
|
if item.reblog != None:
|
||||||
@@ -540,6 +665,22 @@ class BaseBuffer(base.Buffer):
|
|||||||
else:
|
else:
|
||||||
call_threaded(self.session.api_call, call_name="status_unbookmark", preexec_message=_("Removing from bookmarks..."), _sound="favourite.ogg", id=item.id)
|
call_threaded(self.session.api_call, call_name="status_unbookmark", preexec_message=_("Removing from bookmarks..."), _sound="favourite.ogg", id=item.id)
|
||||||
|
|
||||||
|
def mute_conversation(self, event=None, item=None, *args, **kwargs):
|
||||||
|
if item == None:
|
||||||
|
item = self.get_item()
|
||||||
|
if item.reblog != None:
|
||||||
|
item = item.reblog
|
||||||
|
try:
|
||||||
|
item = self.session.api.status(item.id)
|
||||||
|
except MastodonNotFoundError:
|
||||||
|
output.speak(_("No status found with that ID"))
|
||||||
|
return
|
||||||
|
if item.muted == False:
|
||||||
|
call_threaded(self.session.api_call, call_name="status_mute", preexec_message=_("Muting conversation..."), _sound="favourite.ogg", id=item.id)
|
||||||
|
pub.sendMessage("mastodon.mute_cleanup", conversation_id=item.conversation_id, session_name=self.session.get_name())
|
||||||
|
else:
|
||||||
|
call_threaded(self.session.api_call, call_name="status_unmute", preexec_message=_("Unmuting conversation..."), _sound="favourite.ogg", id=item.id)
|
||||||
|
|
||||||
def view_item(self, item=None):
|
def view_item(self, item=None):
|
||||||
if item == None:
|
if item == None:
|
||||||
item = self.get_item()
|
item = self.get_item()
|
||||||
@@ -549,8 +690,7 @@ class BaseBuffer(base.Buffer):
|
|||||||
except MastodonNotFoundError:
|
except MastodonNotFoundError:
|
||||||
output.speak(_("No status found with that ID"))
|
output.speak(_("No status found with that ID"))
|
||||||
return
|
return
|
||||||
# print(post)
|
msg = messages.viewPost(self.session, item, offset_hours=self.session.db["utc_offset"], item_url=self.get_item_url(item=item))
|
||||||
msg = messages.viewPost(item, offset_hours=self.session.db["utc_offset"], item_url=self.get_item_url(item=item))
|
|
||||||
|
|
||||||
def ocr_image(self):
|
def ocr_image(self):
|
||||||
post = self.get_item()
|
post = self.get_item()
|
||||||
@@ -591,8 +731,11 @@ class BaseBuffer(base.Buffer):
|
|||||||
response = viewer.message.ShowModal()
|
response = viewer.message.ShowModal()
|
||||||
viewer.message.Destroy()
|
viewer.message.Destroy()
|
||||||
|
|
||||||
def vote(self):
|
def vote(self, item=None):
|
||||||
|
if item == None:
|
||||||
post = self.get_item()
|
post = self.get_item()
|
||||||
|
else:
|
||||||
|
post = item
|
||||||
if not hasattr(post, "poll") or post.poll == None:
|
if not hasattr(post, "poll") or post.poll == None:
|
||||||
return
|
return
|
||||||
poll = post.poll
|
poll = post.poll
|
||||||
@@ -616,14 +759,14 @@ class BaseBuffer(base.Buffer):
|
|||||||
return
|
return
|
||||||
poll = self.session.api_call(call_name="poll_vote", id=poll.id, choices=options, preexec_message=_("Sending vote..."))
|
poll = self.session.api_call(call_name="poll_vote", id=poll.id, choices=options, preexec_message=_("Sending vote..."))
|
||||||
|
|
||||||
def post_from_error(self, visibility, reply_to, data):
|
def post_from_error(self, visibility, reply_to, data, lang):
|
||||||
title = _("Post")
|
title = _("Post")
|
||||||
caption = _("Write your post here")
|
caption = _("Write your post here")
|
||||||
post = messages.post(session=self.session, title=title, caption=caption)
|
post = messages.post(session=self.session, title=title, caption=caption)
|
||||||
post.set_post_data(visibility=visibility, data=data)
|
post.set_post_data(visibility=visibility, data=data, language=language)
|
||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, posts=post_data, reply_to=reply_to, visibility=post.get_visibility())
|
call_threaded(self.session.send_post, posts=post_data, reply_to=reply_to, visibility=post.get_visibility(), language=post.get_language())
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|||||||
162
src/controller/buffers/mastodon/community.py
Normal file
162
src/controller/buffers/mastodon/community.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import mastodon
|
||||||
|
import widgetUtils
|
||||||
|
import output
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
from sessions.mastodon import utils
|
||||||
|
from . import base
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.buffers.mastodon.community")
|
||||||
|
|
||||||
|
class CommunityBuffer(base.BaseBuffer):
|
||||||
|
def __init__(self, community_url, *args, **kwargs):
|
||||||
|
super(CommunityBuffer, self).__init__(*args, **kwargs)
|
||||||
|
self.community_url = community_url
|
||||||
|
self.community_api = mastodon.Mastodon(api_base_url=self.community_url)
|
||||||
|
self.timeline = kwargs.get("timeline", "local")
|
||||||
|
self.kwargs.pop("timeline")
|
||||||
|
|
||||||
|
def get_buffer_name(self):
|
||||||
|
type = _("Local") if self.timeline == "local" else _("Federated")
|
||||||
|
instance = self.community_url.replace("https://", "")
|
||||||
|
return _(f"{type} timeline for {instance}")
|
||||||
|
|
||||||
|
def start_stream(self, mandatory=False, play_sound=True, avoid_autoreading=False):
|
||||||
|
current_time = time.time()
|
||||||
|
if self.execution_time == 0 or current_time-self.execution_time >= 180 or mandatory==True:
|
||||||
|
self.execution_time = current_time
|
||||||
|
log.debug("Starting stream for buffer %s, account %s and type %s" % (self.name, self.account, self.type))
|
||||||
|
log.debug("args: %s, kwargs: %s" % (self.args, self.kwargs))
|
||||||
|
count = self.session.settings["general"]["max_posts_per_call"]
|
||||||
|
min_id = None
|
||||||
|
# toDo: Implement reverse timelines properly here.
|
||||||
|
if self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||||
|
min_id = max(item.id for item in self.session.db[self.name])
|
||||||
|
try:
|
||||||
|
results = self.community_api.timeline(timeline=self.timeline, min_id=min_id, limit=count, *self.args, **self.kwargs)
|
||||||
|
results.reverse()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error %s" % (str(e)))
|
||||||
|
return
|
||||||
|
number_of_items = self.session.order_buffer(self.name, results)
|
||||||
|
log.debug("Number of items retrieved: %d" % (number_of_items,))
|
||||||
|
self.put_items_on_list(number_of_items)
|
||||||
|
if number_of_items > 0 and self.sound != None and self.session.settings["sound"]["session_mute"] == False and self.name not in self.session.settings["other_buffers"]["muted_buffers"] and play_sound == True:
|
||||||
|
self.session.sound.play(self.sound)
|
||||||
|
# Autoread settings
|
||||||
|
if avoid_autoreading == False and mandatory == True and number_of_items > 0 and self.name in self.session.settings["other_buffers"]["autoread_buffers"]:
|
||||||
|
self.auto_read(number_of_items)
|
||||||
|
return number_of_items
|
||||||
|
|
||||||
|
def get_more_items(self):
|
||||||
|
elements = []
|
||||||
|
if len(self.session.db[self.name]) == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
unpinned_ids = [item.id for item in self.session.db[self.name] if not getattr(item, "pinned", False)]
|
||||||
|
if unpinned_ids:
|
||||||
|
max_id = min(unpinned_ids)
|
||||||
|
else:
|
||||||
|
max_id = min(item.id for item in self.session.db[self.name])
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = self.community_api.timeline(timeline=self.timeline, max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], *self.args, **self.kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error %s" % (str(e)))
|
||||||
|
return
|
||||||
|
items_db = self.session.db[self.name]
|
||||||
|
for i in items:
|
||||||
|
if utils.find_item(i, self.session.db[self.name]) == None:
|
||||||
|
elements.append(i)
|
||||||
|
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||||
|
items_db.insert(0, i)
|
||||||
|
else:
|
||||||
|
items_db.append(i)
|
||||||
|
self.session.db[self.name] = items_db
|
||||||
|
selection = self.buffer.list.get_selected()
|
||||||
|
log.debug("Retrieved %d items from cursored search in function %s." % (len(elements), self.function))
|
||||||
|
safe = True
|
||||||
|
if self.session.settings["general"]["read_preferences_from_instance"]:
|
||||||
|
safe = self.session.expand_spoilers == False
|
||||||
|
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||||
|
for i in elements:
|
||||||
|
post = self.compose_function(i, self.session.db, self.session.settings, self.session.settings["general"]["relative_times"], self.session.settings["general"]["show_screen_names"], safe=safe)
|
||||||
|
self.buffer.list.insert_item(True, *post)
|
||||||
|
else:
|
||||||
|
for i in elements:
|
||||||
|
post = self.compose_function(i, self.session.db, self.session.settings, self.session.settings["general"]["relative_times"], self.session.settings["general"]["show_screen_names"], safe=safe)
|
||||||
|
self.buffer.list.insert_item(False, *post)
|
||||||
|
self.buffer.list.select_item(selection)
|
||||||
|
output.speak(_(u"%s items retrieved") % (str(len(elements))), True)
|
||||||
|
|
||||||
|
def remove_buffer(self, force=False):
|
||||||
|
if force == False:
|
||||||
|
dlg = commonMessageDialogs.remove_buffer()
|
||||||
|
else:
|
||||||
|
dlg = widgetUtils.YES
|
||||||
|
if dlg == widgetUtils.YES:
|
||||||
|
tl_info = f"{self.timeline}@{self.community_url}"
|
||||||
|
self.session.settings["other_buffers"]["communities"].remove(tl_info)
|
||||||
|
self.session.settings.write()
|
||||||
|
if self.name in self.session.db:
|
||||||
|
self.session.db.pop(self.name)
|
||||||
|
return True
|
||||||
|
elif dlg == widgetUtils.NO:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_item_from_instance(self, *args, **kwargs):
|
||||||
|
item = self.get_item()
|
||||||
|
try:
|
||||||
|
results = self.session.api.search(q=item.url, resolve=True, result_type="statuses")
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error when searching for remote post.")
|
||||||
|
return None
|
||||||
|
item = results["statuses"][0]
|
||||||
|
return item
|
||||||
|
|
||||||
|
def reply(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).reply(item=item)
|
||||||
|
|
||||||
|
def send_message(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).send_message(item=item)
|
||||||
|
|
||||||
|
def share_item(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).share_item(item=item)
|
||||||
|
|
||||||
|
def add_to_favorites(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).add_to_favorite(item=item)
|
||||||
|
|
||||||
|
def remove_from_favorites(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).remove_from_favorites(item=item)
|
||||||
|
|
||||||
|
def toggle_favorite(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).toggle_favorite(item=item)
|
||||||
|
|
||||||
|
def toggle_bookmark(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).toggle_bookmark(item=item)
|
||||||
|
|
||||||
|
def vote(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).vote(item=item)
|
||||||
|
|
||||||
|
def view_item(self, *args, **kwargs):
|
||||||
|
item = self.get_item_from_instance()
|
||||||
|
if item != None:
|
||||||
|
super(CommunityBuffer, self).view_item(item=item)
|
||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
import wx
|
import wx
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
import output
|
import output
|
||||||
|
import config
|
||||||
from mastodon import MastodonNotFoundError
|
from mastodon import MastodonNotFoundError
|
||||||
from controller.mastodon import messages
|
from controller.mastodon import messages
|
||||||
from controller.buffers.mastodon.base import BaseBuffer
|
from controller.buffers.mastodon.base import BaseBuffer
|
||||||
@@ -162,6 +163,8 @@ class ConversationListBuffer(BaseBuffer):
|
|||||||
|
|
||||||
def onFocus(self, *args, **kwargs):
|
def onFocus(self, *args, **kwargs):
|
||||||
post = self.get_item()
|
post = self.get_item()
|
||||||
|
if config.app["app-settings"]["read_long_posts_in_gui"] == True and self.buffer.list.list.HasFocus():
|
||||||
|
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
|
||||||
if self.session.settings['sound']['indicate_audio'] and utils.is_audio_or_video(post):
|
if self.session.settings['sound']['indicate_audio'] and utils.is_audio_or_video(post):
|
||||||
self.session.sound.play("audio.ogg")
|
self.session.sound.play("audio.ogg")
|
||||||
if self.session.settings['sound']['indicate_img'] and utils.is_image(post):
|
if self.session.settings['sound']['indicate_img'] and utils.is_image(post):
|
||||||
@@ -188,7 +191,7 @@ class ConversationListBuffer(BaseBuffer):
|
|||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, reply_to=item.id, posts=post_data, visibility=visibility)
|
call_threaded(self.session.send_post, reply_to=item.id, posts=post_data, visibility=visibility, language=post.get_language())
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,22 @@ class MentionsBuffer(BaseBuffer):
|
|||||||
|
|
||||||
def get_more_items(self):
|
def get_more_items(self):
|
||||||
elements = []
|
elements = []
|
||||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
if len(self.session.db[self.name]) == 0:
|
||||||
max_id = self.session.db[self.name][0].id
|
return
|
||||||
else:
|
|
||||||
max_id = self.session.db[self.name][-1].id
|
# In mentions buffer, items are notification objects which don't have 'pinned' attribute directly.
|
||||||
|
# But we check the status attached to the notification if it exists.
|
||||||
|
# However, notifications are strictly chronological usually. Pinned mentions don't exist?
|
||||||
|
# But let's stick to the safe ID extraction.
|
||||||
|
# The logic here is tricky because self.session.db stores notification objects, but sometimes just dicts?
|
||||||
|
# Let's assume they are objects with 'id' attribute.
|
||||||
|
# Notifications don't have 'pinned', so we just take the min ID.
|
||||||
|
# But wait, did I change this file previously to use min()? Yes.
|
||||||
|
# Is there any case where a notification ID is "pinned" (old)? No.
|
||||||
|
# So min() should be fine here. But for consistency with other buffers if any weird logic exists...
|
||||||
|
# Actually, let's keep min() as notifications don't support pinning.
|
||||||
|
|
||||||
|
max_id = min(item.id for item in self.session.db[self.name])
|
||||||
try:
|
try:
|
||||||
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], types=["mention"], *self.args, **self.kwargs)
|
items = getattr(self.session.api, self.function)(max_id=max_id, limit=self.session.settings["general"]["max_posts_per_call"], types=["mention"], *self.args, **self.kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -56,6 +68,9 @@ class MentionsBuffer(BaseBuffer):
|
|||||||
items_db = self.session.db[self.name]
|
items_db = self.session.db[self.name]
|
||||||
for i in items:
|
for i in items:
|
||||||
if utils.find_item(i, self.session.db[self.name]) == None:
|
if utils.find_item(i, self.session.db[self.name]) == None:
|
||||||
|
filter_status = utils.evaluate_filters(post=i, current_context=utils.get_current_context(self.name))
|
||||||
|
if filter_status == "hide":
|
||||||
|
continue
|
||||||
elements.append(i)
|
elements.append(i)
|
||||||
if self.session.settings["general"]["reverse_timelines"] == False:
|
if self.session.settings["general"]["reverse_timelines"] == False:
|
||||||
items_db.insert(0, i)
|
items_db.insert(0, i)
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import arrow
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
|
import wx
|
||||||
import output
|
import output
|
||||||
|
import languageHandler
|
||||||
|
import config
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from controller.buffers.mastodon.base import BaseBuffer
|
from controller.buffers.mastodon.base import BaseBuffer
|
||||||
from controller.mastodon import messages
|
from controller.mastodon import messages
|
||||||
@@ -38,8 +42,11 @@ class NotificationsBuffer(BaseBuffer):
|
|||||||
original_date = arrow.get(self.session.db[self.name][self.buffer.list.get_selected()].created_at)
|
original_date = arrow.get(self.session.db[self.name][self.buffer.list.get_selected()].created_at)
|
||||||
ts = original_date.humanize(locale=languageHandler.getLanguage())
|
ts = original_date.humanize(locale=languageHandler.getLanguage())
|
||||||
self.buffer.list.list.SetItem(self.buffer.list.get_selected(), 1, ts)
|
self.buffer.list.list.SetItem(self.buffer.list.get_selected(), 1, ts)
|
||||||
|
if config.app["app-settings"]["read_long_posts_in_gui"] == True and self.buffer.list.list.HasFocus():
|
||||||
|
wx.CallLater(40, output.speak, self.get_message(), interrupt=True)
|
||||||
|
|
||||||
def bind_events(self):
|
def bind_events(self):
|
||||||
|
self.buffer.set_focus_function(self.onFocus)
|
||||||
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
widgetUtils.connect_event(self.buffer.list.list, widgetUtils.KEYPRESS, self.get_event)
|
||||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.post_status, self.buffer.post)
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.post_status, self.buffer.post)
|
||||||
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.destroy_status, self.buffer.dismiss)
|
widgetUtils.connect_event(self.buffer, widgetUtils.BUTTON_PRESSED, self.destroy_status, self.buffer.dismiss)
|
||||||
@@ -119,7 +126,7 @@ class NotificationsBuffer(BaseBuffer):
|
|||||||
response = post.message.ShowModal()
|
response = post.message.ShowModal()
|
||||||
if response == wx.ID_OK:
|
if response == wx.ID_OK:
|
||||||
post_data = post.get_data()
|
post_data = post.get_data()
|
||||||
call_threaded(self.session.send_post, posts=post_data, visibility="direct")
|
call_threaded(self.session.send_post, posts=post_data, visibility="direct", language=post.get_language())
|
||||||
if hasattr(post.message, "destroy"):
|
if hasattr(post.message, "destroy"):
|
||||||
post.message.destroy()
|
post.message.destroy()
|
||||||
|
|
||||||
@@ -154,6 +161,13 @@ class NotificationsBuffer(BaseBuffer):
|
|||||||
menu = menus.notification(notification.type)
|
menu = menus.notification(notification.type)
|
||||||
if self.is_post():
|
if self.is_post():
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.reply, menuitem=menu.reply)
|
||||||
|
# Enable/disable edit based on whether the post belongs to the user
|
||||||
|
if hasattr(menu, 'edit'):
|
||||||
|
status = self.get_post()
|
||||||
|
if status and status.account.id == self.session.db["user_id"] and status.reblog == None:
|
||||||
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.edit_status, menuitem=menu.edit)
|
||||||
|
else:
|
||||||
|
menu.edit.Enable(False)
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.user_actions, menuitem=menu.userActions)
|
||||||
if self.can_share() == True:
|
if self.can_share() == True:
|
||||||
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
widgetUtils.connect_event(menu, widgetUtils.MENU, self.share_item, menuitem=menu.boost)
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ class SearchBuffer(BaseBuffer):
|
|||||||
self.execution_time = current_time
|
self.execution_time = current_time
|
||||||
min_id = None
|
min_id = None
|
||||||
if self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
if self.name in self.session.db and len(self.session.db[self.name]) > 0:
|
||||||
if self.session.settings["general"]["reverse_timelines"]:
|
min_id = max(item.id for item in self.session.db[self.name])
|
||||||
min_id = self.session.db[self.name][0].id
|
|
||||||
else:
|
|
||||||
min_id = self.session.db[self.name][-1].id
|
|
||||||
try:
|
try:
|
||||||
results = getattr(self.session.api, self.function)(min_id=min_id, **self.kwargs)
|
results = getattr(self.session.api, self.function)(min_id=min_id, **self.kwargs)
|
||||||
except Exception as mess:
|
except Exception as mess:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import logging
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
import wx
|
import wx
|
||||||
import requests
|
import requests
|
||||||
|
import asyncio
|
||||||
import keystrokeEditor
|
import keystrokeEditor
|
||||||
import sessions
|
import sessions
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
@@ -25,6 +26,7 @@ from mysc import localization
|
|||||||
from mysc.thread_utils import call_threaded
|
from mysc.thread_utils import call_threaded
|
||||||
from mysc.repeating_timer import RepeatingTimer
|
from mysc.repeating_timer import RepeatingTimer
|
||||||
from controller.mastodon import handler as MastodonHandler
|
from controller.mastodon import handler as MastodonHandler
|
||||||
|
from controller.blueski import handler as BlueskiHandler # Added import
|
||||||
from . import settings, userAlias
|
from . import settings, userAlias
|
||||||
|
|
||||||
log = logging.getLogger("mainController")
|
log = logging.getLogger("mainController")
|
||||||
@@ -93,6 +95,17 @@ class Controller(object):
|
|||||||
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
|
[results.append(self.search_buffer(i.name, i.account)) for i in buffers if i.account == account and (i.type != "account")]
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def get_handler(self, type):
|
||||||
|
"""Return the controller handler for a given session type."""
|
||||||
|
try:
|
||||||
|
if type == "mastodon":
|
||||||
|
return MastodonHandler.Handler()
|
||||||
|
if type == "blueski":
|
||||||
|
return BlueskiHandler.Handler()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error creating handler for type %s", type)
|
||||||
|
return None
|
||||||
|
|
||||||
def bind_other_events(self):
|
def bind_other_events(self):
|
||||||
""" Binds the local application events with their functions."""
|
""" Binds the local application events with their functions."""
|
||||||
log.debug("Binding other application events...")
|
log.debug("Binding other application events...")
|
||||||
@@ -107,6 +120,7 @@ class Controller(object):
|
|||||||
pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed")
|
pub.subscribe(self.invisible_shorcuts_changed, "invisible-shorcuts-changed")
|
||||||
pub.subscribe(self.create_account_buffer, "core.create_account")
|
pub.subscribe(self.create_account_buffer, "core.create_account")
|
||||||
pub.subscribe(self.change_buffer_title, "core.change_buffer_title")
|
pub.subscribe(self.change_buffer_title, "core.change_buffer_title")
|
||||||
|
pub.subscribe(self.handle_compose_dialog_send, "compose_dialog.send_post") # For new compose dialog
|
||||||
|
|
||||||
# Mastodon specific events.
|
# Mastodon specific events.
|
||||||
pub.subscribe(self.mastodon_new_item, "mastodon.new_item")
|
pub.subscribe(self.mastodon_new_item, "mastodon.new_item")
|
||||||
@@ -114,6 +128,9 @@ class Controller(object):
|
|||||||
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
|
pub.subscribe(self.mastodon_new_conversation, "mastodon.conversation_received")
|
||||||
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
|
pub.subscribe(self.mastodon_error_post, "mastodon.error_post")
|
||||||
|
|
||||||
|
# Bluesky specific events.
|
||||||
|
pub.subscribe(self.blueski_new_item, "blueski.new_item")
|
||||||
|
|
||||||
# connect application events to GUI
|
# connect application events to GUI
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
|
widgetUtils.connect_event(self.view, widgetUtils.CLOSE_EVENT, self.exit_)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_hide, menuitem=self.view.show_hide)
|
||||||
@@ -153,6 +170,7 @@ class Controller(object):
|
|||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.toggle_buffer_mute, self.view.mute_buffer)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.toggle_buffer_mute, self.view.mute_buffer)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_timeline, self.view.timeline)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_timeline, self.view.timeline)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_favs_timeline, self.view.favs)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_favs_timeline, self.view.favs)
|
||||||
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.community_timeline, self.view.community_timeline)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_conversation, menuitem=self.view.view_conversation)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.open_conversation, menuitem=self.view.view_conversation)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.seekLeft, menuitem=self.view.seekLeft)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.seekLeft, menuitem=self.view.seekLeft)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.seekRight, menuitem=self.view.seekRight)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.seekRight, menuitem=self.view.seekRight)
|
||||||
@@ -164,6 +182,8 @@ class Controller(object):
|
|||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.update_buffer, self.view.update_buffer)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.update_buffer, self.view.update_buffer)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.manage_aliases, self.view.manageAliases)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.manage_aliases, self.view.manageAliases)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.report_error, self.view.reportError)
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.report_error, self.view.reportError)
|
||||||
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.create_filter, self.view.filter)
|
||||||
|
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.manage_filters, self.view.manage_filters)
|
||||||
|
|
||||||
def set_systray_icon(self):
|
def set_systray_icon(self):
|
||||||
self.systrayIcon = sysTrayIcon.SysTrayIcon()
|
self.systrayIcon = sysTrayIcon.SysTrayIcon()
|
||||||
@@ -188,9 +208,11 @@ class Controller(object):
|
|||||||
|
|
||||||
def get_handler(self, type):
|
def get_handler(self, type):
|
||||||
handler = self.handlers.get(type)
|
handler = self.handlers.get(type)
|
||||||
if handler == None:
|
if handler is None:
|
||||||
if type == "mastodon":
|
if type == "mastodon":
|
||||||
handler = MastodonHandler.Handler()
|
handler = MastodonHandler.Handler()
|
||||||
|
elif type == "blueski":
|
||||||
|
handler = BlueskiHandler.Handler()
|
||||||
self.handlers[type] = handler
|
self.handlers[type] = handler
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
@@ -201,7 +223,7 @@ class Controller(object):
|
|||||||
# main window
|
# main window
|
||||||
self.view = view.mainFrame()
|
self.view = view.mainFrame()
|
||||||
# buffers list.
|
# buffers list.
|
||||||
self.buffers = []
|
self.buffers: list[buffers.base.Buffer] = [] # Added type hint
|
||||||
self.started = False
|
self.started = False
|
||||||
# accounts list.
|
# accounts list.
|
||||||
self.accounts = []
|
self.accounts = []
|
||||||
@@ -232,14 +254,26 @@ class Controller(object):
|
|||||||
for i in sessions.sessions:
|
for i in sessions.sessions:
|
||||||
log.debug("Working on session %s" % (i,))
|
log.debug("Working on session %s" % (i,))
|
||||||
if sessions.sessions[i].is_logged == False:
|
if sessions.sessions[i].is_logged == False:
|
||||||
|
if sessions.sessions[i].session_id in config.app["sessions"]["ignored_sessions"]:
|
||||||
self.create_ignored_session_buffer(sessions.sessions[i])
|
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||||
continue
|
continue
|
||||||
# Valid types currently are mastodon (Work in progress)
|
# Try auto-login for sessions if credentials exist
|
||||||
# More can be added later.
|
try:
|
||||||
valid_session_types = ["mastodon"]
|
sessions.sessions[i].login()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Auto-login attempt failed for session %s", i)
|
||||||
|
if sessions.sessions[i].is_logged == False:
|
||||||
|
self.create_ignored_session_buffer(sessions.sessions[i])
|
||||||
|
continue
|
||||||
|
# Supported session types
|
||||||
|
valid_session_types = ["mastodon", "blueski"]
|
||||||
if sessions.sessions[i].type in valid_session_types:
|
if sessions.sessions[i].type in valid_session_types:
|
||||||
|
try:
|
||||||
handler = self.get_handler(type=sessions.sessions[i].type)
|
handler = self.get_handler(type=sessions.sessions[i].type)
|
||||||
|
if handler is not None:
|
||||||
handler.create_buffers(sessions.sessions[i], controller=self)
|
handler.create_buffers(sessions.sessions[i], controller=self)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error creating buffers for session %s (%s)", i, sessions.sessions[i].type)
|
||||||
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
|
log.debug("Setting updates to buffers every %d seconds..." % (60*config.app["app-settings"]["update_period"],))
|
||||||
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
|
self.update_buffers_function = RepeatingTimer(60*config.app["app-settings"]["update_period"], self.update_buffers)
|
||||||
self.update_buffers_function.start()
|
self.update_buffers_function.start()
|
||||||
@@ -248,29 +282,83 @@ class Controller(object):
|
|||||||
""" Starts all buffer objects. Loads their items."""
|
""" Starts all buffer objects. Loads their items."""
|
||||||
for i in sessions.sessions:
|
for i in sessions.sessions:
|
||||||
if sessions.sessions[i].is_logged == False: continue
|
if sessions.sessions[i].is_logged == False: continue
|
||||||
self.start_buffers(sessions.sessions[i])
|
call_threaded(self._start_session_buffers, sessions.sessions[i])
|
||||||
self.set_buffer_positions(sessions.sessions[i])
|
if len(sessions.sessions) > 0 and config.app["app-settings"]["play_ready_sound"] == True:
|
||||||
if hasattr(sessions.sessions[i], "start_streaming"):
|
|
||||||
sessions.sessions[i].start_streaming()
|
|
||||||
if config.app["app-settings"]["play_ready_sound"] == True:
|
|
||||||
sessions.sessions[list(sessions.sessions.keys())[0]].sound.play("ready.ogg")
|
sessions.sessions[list(sessions.sessions.keys())[0]].sound.play("ready.ogg")
|
||||||
if config.app["app-settings"]["speak_ready_msg"] == True:
|
if config.app["app-settings"]["speak_ready_msg"] == True:
|
||||||
output.speak(_(u"Ready"))
|
output.speak(_(u"Ready"))
|
||||||
self.started = True
|
self.started = True
|
||||||
if len(self.accounts) > 0:
|
if len(self.accounts) > 0:
|
||||||
b = self.get_first_buffer(self.accounts[0])
|
b = self.get_first_buffer(self.accounts[0])
|
||||||
|
self.menubar_current_handler = b.session.type
|
||||||
self.update_menus(handler=self.get_handler(b.session.type))
|
self.update_menus(handler=self.get_handler(b.session.type))
|
||||||
|
|
||||||
|
def _start_session_buffers(self, session):
|
||||||
|
"""Helper to start buffers for a session in a background thread."""
|
||||||
|
try:
|
||||||
|
self.start_buffers(session)
|
||||||
|
self.set_buffer_positions(session)
|
||||||
|
if hasattr(session, "start_streaming"):
|
||||||
|
session.start_streaming()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error starting buffers for session %s", session.session_id)
|
||||||
|
|
||||||
def create_ignored_session_buffer(self, session):
|
def create_ignored_session_buffer(self, session):
|
||||||
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
|
pub.sendMessage("core.create_account", name=session.get_name(), session_id=session.session_id)
|
||||||
|
|
||||||
def login_account(self, session_id):
|
def login_account(self, session_id):
|
||||||
|
session = None
|
||||||
for i in sessions.sessions:
|
for i in sessions.sessions:
|
||||||
if sessions.sessions[i].session_id == session_id: session = sessions.sessions[i]
|
if sessions.sessions[i].session_id == session_id:
|
||||||
|
session = sessions.sessions[i]
|
||||||
|
break
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
|
||||||
|
old_name = session.get_name()
|
||||||
|
try:
|
||||||
session.login()
|
session.login()
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Login failed for session %s", session_id)
|
||||||
|
output.speak(_("Login failed for {0}: {1}").format(old_name, str(e)), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not session.logged:
|
||||||
|
output.speak(_("Login failed for {0}. Please check your credentials.").format(old_name), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_name = session.get_name()
|
||||||
|
if old_name != new_name:
|
||||||
|
log.info(f"Account name changed from {old_name} to {new_name} after login")
|
||||||
|
if self.current_account == old_name:
|
||||||
|
self.current_account = new_name
|
||||||
|
if old_name in self.accounts:
|
||||||
|
idx = self.accounts.index(old_name)
|
||||||
|
self.accounts[idx] = new_name
|
||||||
|
else:
|
||||||
|
self.accounts.append(new_name)
|
||||||
|
|
||||||
|
# Update root buffer name and account
|
||||||
|
for b in self.buffers:
|
||||||
|
if b.account == old_name:
|
||||||
|
b.account = new_name
|
||||||
|
if hasattr(b, "buffer"):
|
||||||
|
b.buffer.account = new_name
|
||||||
|
# If this is the root node, its name matches old_name (e.g. "Bluesky")
|
||||||
|
if b.name == old_name:
|
||||||
|
b.name = new_name
|
||||||
|
if hasattr(b, "buffer"):
|
||||||
|
b.buffer.name = new_name
|
||||||
|
|
||||||
|
# Update tree node label
|
||||||
|
self.change_buffer_title(old_name, old_name, new_name)
|
||||||
|
|
||||||
handler = self.get_handler(type=session.type)
|
handler = self.get_handler(type=session.type)
|
||||||
if handler != None and hasattr(handler, "create_buffers"):
|
if handler != None and hasattr(handler, "create_buffers"):
|
||||||
|
try:
|
||||||
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
handler.create_buffers(session=session, controller=self, createAccounts=False)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error creating buffers after login for session %s (%s)", session.session_id, session.type)
|
||||||
self.start_buffers(session)
|
self.start_buffers(session)
|
||||||
if hasattr(session, "start_streaming"):
|
if hasattr(session, "start_streaming"):
|
||||||
session.start_streaming()
|
session.start_streaming()
|
||||||
@@ -284,24 +372,82 @@ class Controller(object):
|
|||||||
self.view.add_buffer(account.buffer , name=name)
|
self.view.add_buffer(account.buffer , name=name)
|
||||||
|
|
||||||
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
|
def create_buffer(self, buffer_type="baseBuffer", session_type="twitter", buffer_title="", parent_tab=None, start=False, kwargs={}):
|
||||||
|
# Copy kwargs to avoid mutating a shared dict across calls
|
||||||
|
if not isinstance(kwargs, dict):
|
||||||
|
kwargs = {}
|
||||||
|
else:
|
||||||
|
kwargs = dict(kwargs)
|
||||||
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
|
log.debug("Creating buffer of type {0} with parent_tab of {2} arguments {1}".format(buffer_type, kwargs, parent_tab))
|
||||||
if kwargs.get("parent") == None:
|
if kwargs.get("parent") == None:
|
||||||
kwargs["parent"] = self.view.nb
|
kwargs["parent"] = self.view.nb
|
||||||
if not hasattr(buffers, session_type):
|
if not hasattr(buffers, session_type) and session_type != "blueski": # Allow blueski to be handled separately
|
||||||
raise AttributeError("Session type %s does not exist yet." % (session_type))
|
raise AttributeError("Session type %s does not exist yet." % (session_type))
|
||||||
|
|
||||||
|
try:
|
||||||
|
buffer_panel_class = None
|
||||||
|
if session_type == "blueski":
|
||||||
|
from controller.buffers.blueski import timeline as BlueskiTimelines
|
||||||
|
from controller.buffers.blueski import user as BlueskiUsers
|
||||||
|
from controller.buffers.blueski import chat as BlueskiChats
|
||||||
|
|
||||||
|
if "user_id" in kwargs and "session" not in kwargs:
|
||||||
|
kwargs["session"] = sessions.sessions.get(kwargs["user_id"])
|
||||||
|
|
||||||
|
if "name" not in kwargs: kwargs["name"] = buffer_title
|
||||||
|
|
||||||
|
buffer_map = {
|
||||||
|
"home_timeline": BlueskiTimelines.HomeTimeline,
|
||||||
|
"following_timeline": BlueskiTimelines.FollowingTimeline,
|
||||||
|
"notifications": BlueskiTimelines.NotificationBuffer,
|
||||||
|
"conversation": BlueskiTimelines.Conversation,
|
||||||
|
"likes": BlueskiTimelines.LikesBuffer,
|
||||||
|
"MentionsBuffer": BlueskiTimelines.MentionsBuffer,
|
||||||
|
"mentions": BlueskiTimelines.MentionsBuffer,
|
||||||
|
"SentBuffer": BlueskiTimelines.SentBuffer,
|
||||||
|
"sent": BlueskiTimelines.SentBuffer,
|
||||||
|
"SearchBuffer": BlueskiTimelines.SearchBuffer,
|
||||||
|
"search": BlueskiTimelines.SearchBuffer,
|
||||||
|
"UserBuffer": BlueskiUsers.UserBuffer,
|
||||||
|
"FollowersBuffer": BlueskiUsers.FollowersBuffer,
|
||||||
|
"FollowingBuffer": BlueskiUsers.FollowingBuffer,
|
||||||
|
"BlocksBuffer": BlueskiUsers.BlocksBuffer,
|
||||||
|
"PostUserListBuffer": BlueskiUsers.PostUserListBuffer,
|
||||||
|
"ConversationListBuffer": BlueskiChats.ConversationListBuffer,
|
||||||
|
"ChatMessageBuffer": BlueskiChats.ChatBuffer,
|
||||||
|
"chat_messages": BlueskiChats.ChatBuffer,
|
||||||
|
"UserTimeline": BlueskiTimelines.UserTimeline,
|
||||||
|
"user_timeline": BlueskiTimelines.UserTimeline,
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer_panel_class = buffer_map.get(buffer_type)
|
||||||
|
if buffer_panel_class is None:
|
||||||
|
# Fallback for others including user_timeline to HomeTimeline for now
|
||||||
|
log.warning(f"Unsupported Blueski buffer type: {buffer_type}. Falling back to HomeTimeline.")
|
||||||
|
buffer_panel_class = BlueskiTimelines.HomeTimeline
|
||||||
|
else: # Existing logic for other session types
|
||||||
available_buffers = getattr(buffers, session_type)
|
available_buffers = getattr(buffers, session_type)
|
||||||
if not hasattr(available_buffers, buffer_type):
|
if not hasattr(available_buffers, buffer_type):
|
||||||
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
raise AttributeError("Specified buffer type does not exist: %s" % (buffer_type,))
|
||||||
buffer = getattr(available_buffers, buffer_type)(**kwargs)
|
buffer_panel_class = getattr(available_buffers, buffer_type)
|
||||||
|
|
||||||
|
# Instantiate the panel
|
||||||
|
# Ensure 'parent' kwarg is correctly set if not already
|
||||||
|
if "parent" not in kwargs:
|
||||||
|
kwargs["parent"] = self.view.nb # self.view.nb is the wx.Treebook
|
||||||
|
|
||||||
|
buffer = buffer_panel_class(**kwargs) # This is the wx.Panel instance
|
||||||
|
buffer.controller = self
|
||||||
|
|
||||||
if start:
|
if start:
|
||||||
if kwargs.get("function") == "user_timeline":
|
|
||||||
try:
|
try:
|
||||||
buffer.start_stream(play_sound=False)
|
if hasattr(buffer, "start_stream"):
|
||||||
|
if kwargs.get("function") == "user_timeline":
|
||||||
|
buffer.start_stream(mandatory=True, play_sound=False)
|
||||||
|
else:
|
||||||
|
buffer.start_stream(mandatory=True, play_sound=True)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
commonMessageDialogs.unauthorized()
|
commonMessageDialogs.unauthorized()
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
call_threaded(buffer.start_stream)
|
|
||||||
self.buffers.append(buffer)
|
self.buffers.append(buffer)
|
||||||
if parent_tab == None:
|
if parent_tab == None:
|
||||||
log.debug("Appending buffer {}...".format(buffer,))
|
log.debug("Appending buffer {}...".format(buffer,))
|
||||||
@@ -309,6 +455,8 @@ class Controller(object):
|
|||||||
else:
|
else:
|
||||||
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
self.view.insert_buffer(buffer.buffer, buffer_title, parent_tab)
|
||||||
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
log.debug("Inserting buffer {0} into control {1}".format(buffer, parent_tab))
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error creating buffer '%s' for session_type '%s'", buffer_type, session_type)
|
||||||
|
|
||||||
def set_buffer_positions(self, session):
|
def set_buffer_positions(self, session):
|
||||||
"Sets positions for buffers if values exist in the database."
|
"Sets positions for buffers if values exist in the database."
|
||||||
@@ -446,6 +594,15 @@ class Controller(object):
|
|||||||
buffer = self.search_buffer(buffer.name, buffer.account)
|
buffer = self.search_buffer(buffer.name, buffer.account)
|
||||||
buffer.destroy_status()
|
buffer.destroy_status()
|
||||||
|
|
||||||
|
def edit_post(self, *args, **kwargs):
|
||||||
|
""" Edits a post in the current buffer.
|
||||||
|
Users can only edit their own posts."""
|
||||||
|
buffer = self.view.get_current_buffer()
|
||||||
|
if hasattr(buffer, "account"):
|
||||||
|
buffer = self.search_buffer(buffer.name, buffer.account)
|
||||||
|
if hasattr(buffer, "edit_status"):
|
||||||
|
buffer.edit_status()
|
||||||
|
|
||||||
def exit(self, *args, **kwargs):
|
def exit(self, *args, **kwargs):
|
||||||
if config.app["app-settings"]["ask_at_exit"] == True:
|
if config.app["app-settings"]["ask_at_exit"] == True:
|
||||||
answer = commonMessageDialogs.exit_dialog(self.view)
|
answer = commonMessageDialogs.exit_dialog(self.view)
|
||||||
@@ -491,30 +648,216 @@ class Controller(object):
|
|||||||
if hasattr(buffer, "post_status"):
|
if hasattr(buffer, "post_status"):
|
||||||
buffer.post_status()
|
buffer.post_status()
|
||||||
|
|
||||||
|
def handle_compose_dialog_send(self, session, text, files, reply_to, cw_text, is_sensitive, kwargs, dialog_instance):
|
||||||
|
"""Handles the actual sending of a post after ComposeDialog publishes data."""
|
||||||
|
async def do_send_post():
|
||||||
|
try:
|
||||||
|
wx.CallAfter(dialog_instance.send_btn.Disable)
|
||||||
|
wx.CallAfter(wx.BeginBusyCursor)
|
||||||
|
|
||||||
|
post_uri = await session.send_message(
|
||||||
|
message=text,
|
||||||
|
files=files,
|
||||||
|
reply_to=reply_to,
|
||||||
|
cw_text=cw_text,
|
||||||
|
is_sensitive=is_sensitive,
|
||||||
|
**kwargs
|
||||||
|
)
|
||||||
|
if post_uri:
|
||||||
|
output.speak(_("Post sent successfully!"), True)
|
||||||
|
wx.CallAfter(dialog_instance.EndModal, wx.ID_OK)
|
||||||
|
# Optionally, add to relevant buffer or update UI
|
||||||
|
# This might involve fetching the new post and adding to message_cache and posts_buffer
|
||||||
|
# new_post_data = await session.util.get_post_by_uri(post_uri) # Assuming such a util method
|
||||||
|
# if new_post_data:
|
||||||
|
# await self.check_buffers(new_post_data) # check_buffers needs to handle PostView or dict
|
||||||
|
else:
|
||||||
|
# This case should ideally be handled by send_message raising an error
|
||||||
|
output.speak(_("Failed to send post. The server did not confirm the post creation."), True)
|
||||||
|
wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||||
|
|
||||||
|
except NotificationError as e:
|
||||||
|
logger.error(f"NotificationError sending post via dialog: {e}", exc_info=True)
|
||||||
|
output.speak(_("Error sending post: {error}").format(error=str(e)), True)
|
||||||
|
wx.CallAfter(wx.MessageBox, str(e), _("Post Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||||
|
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error sending post via dialog: {e}", exc_info=True)
|
||||||
|
output.speak(_("An unexpected error occurred: {error}").format(error=str(e)), True)
|
||||||
|
wx.CallAfter(wx.MessageBox, str(e), _("Error"), wx.OK | wx.ICON_ERROR, dialog_instance)
|
||||||
|
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(dialog_instance.send_btn.Enable, True)
|
||||||
|
finally:
|
||||||
|
if not dialog_instance.IsBeingDeleted(): wx.CallAfter(wx.EndBusyCursor)
|
||||||
|
|
||||||
|
asyncio.create_task(do_send_post())
|
||||||
|
|
||||||
|
|
||||||
def post_reply(self, *args, **kwargs):
|
def post_reply(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "reply"):
|
if hasattr(buffer, "reply"):
|
||||||
return buffer.reply()
|
return buffer.reply()
|
||||||
|
|
||||||
|
|
||||||
def send_dm(self, *args, **kwargs):
|
def send_dm(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
|
if buffer is None:
|
||||||
|
output.speak(_("No buffer selected."), True)
|
||||||
|
return
|
||||||
if hasattr(buffer, "send_message"):
|
if hasattr(buffer, "send_message"):
|
||||||
buffer.send_message()
|
buffer.send_message()
|
||||||
|
else:
|
||||||
|
output.speak(_("Cannot send messages from this buffer."), True)
|
||||||
|
|
||||||
def post_retweet(self, *args, **kwargs):
|
def post_retweet(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "share_item"):
|
if hasattr(buffer, "share_item"):
|
||||||
return buffer.share_item()
|
return buffer.share_item()
|
||||||
|
session = getattr(buffer, "session", None)
|
||||||
|
if not session:
|
||||||
|
return
|
||||||
|
if getattr(session, "type", "") == "blueski":
|
||||||
|
item_uri = None
|
||||||
|
if hasattr(buffer, "get_selected_item_id"):
|
||||||
|
item_uri = buffer.get_selected_item_id()
|
||||||
|
if not item_uri:
|
||||||
|
output.speak(_("No item selected."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.showing == False:
|
||||||
|
dlg = wx.TextEntryDialog(None, _("Write your quote (optional):"), _("Quote"))
|
||||||
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
|
text = dlg.GetValue().strip()
|
||||||
|
dlg.Destroy()
|
||||||
|
try:
|
||||||
|
if text:
|
||||||
|
uri = session.send_message(text, quote_uri=item_uri)
|
||||||
|
if uri:
|
||||||
|
output.speak(_("Quote posted."), True)
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to send quote."), True)
|
||||||
|
else:
|
||||||
|
# Confirm repost (share) depending on preference (boost_mode)
|
||||||
|
ask = True
|
||||||
|
try:
|
||||||
|
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||||
|
except Exception:
|
||||||
|
ask = True
|
||||||
|
if ask:
|
||||||
|
confirm = wx.MessageDialog(None, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||||
|
if confirm.ShowModal() != wx.ID_YES:
|
||||||
|
confirm.Destroy()
|
||||||
|
return
|
||||||
|
confirm.Destroy()
|
||||||
|
r_uri = session.repost(item_uri)
|
||||||
|
if r_uri:
|
||||||
|
output.speak(_("Post shared."), True)
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to share post."), True)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error sharing/quoting Bluesky post (invisible)")
|
||||||
|
output.speak(_("An error occurred while sharing the post."), True)
|
||||||
|
else:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
|
||||||
|
from wxUI.dialogs.blueski.postDialogs import Post as ATPostDialog
|
||||||
|
dlg = ATPostDialog(caption=_("Quote post"))
|
||||||
|
if dlg.ShowModal() == wx.ID_OK:
|
||||||
|
text, files, cw_text, langs = dlg.get_payload()
|
||||||
|
dlg.Destroy()
|
||||||
|
try:
|
||||||
|
if text or files or cw_text:
|
||||||
|
uri = session.send_message(text, files=files, cw_text=cw_text, is_sensitive=bool(cw_text), languages=langs, quote_uri=item_uri)
|
||||||
|
if uri:
|
||||||
|
output.speak(_("Quote posted."), True)
|
||||||
|
try:
|
||||||
|
if hasattr(buffer, "start_stream"):
|
||||||
|
buffer.start_stream(mandatory=False, play_sound=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to send quote."), True)
|
||||||
|
else:
|
||||||
|
# Confirm repost without comment depending on preference
|
||||||
|
ask = True
|
||||||
|
try:
|
||||||
|
ask = session.settings["general"].get("boost_mode", "ask") == "ask"
|
||||||
|
except Exception:
|
||||||
|
ask = True
|
||||||
|
if ask:
|
||||||
|
confirm = wx.MessageDialog(self.view, _("Would you like to share this post?"), _("Boost"), wx.YES_NO|wx.ICON_QUESTION)
|
||||||
|
if confirm.ShowModal() != wx.ID_YES:
|
||||||
|
confirm.Destroy()
|
||||||
|
return
|
||||||
|
confirm.Destroy()
|
||||||
|
r_uri = session.repost(item_uri)
|
||||||
|
if r_uri:
|
||||||
|
output.speak(_("Post shared."), True)
|
||||||
|
else:
|
||||||
|
output.speak(_("Failed to share post."), True)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Error sharing/quoting Bluesky post (dialog)")
|
||||||
|
output.speak(_("An error occurred while sharing the post."), True)
|
||||||
|
else:
|
||||||
|
dlg.Destroy()
|
||||||
|
return
|
||||||
|
|
||||||
def add_to_favourites(self, *args, **kwargs):
|
def add_to_favourites(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "add_to_favorites"):
|
if hasattr(buffer, "add_to_favorites"): # Generic buffer method
|
||||||
return buffer.add_to_favorites()
|
return buffer.add_to_favorites()
|
||||||
|
elif hasattr(buffer, "toggle_favorite"):
|
||||||
|
return buffer.toggle_favorite()
|
||||||
|
elif buffer.session and buffer.session.KIND == "blueski":
|
||||||
|
# Fallback if buffer doesn't have the method but session is blueski (e.g. ChatBuffer)
|
||||||
|
# Chat messages can't be liked yet in this implementation, or handled by specific buffer
|
||||||
|
output.speak(_("This item cannot be liked."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def remove_from_favourites(self, *args, **kwargs):
|
def remove_from_favourites(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
if hasattr(buffer, "remove_from_favorites"):
|
if hasattr(buffer, "remove_from_favorites"): # Generic buffer method
|
||||||
return buffer.remove_from_favorites()
|
return buffer.remove_from_favorites()
|
||||||
|
elif buffer.session and buffer.session.KIND == "blueski":
|
||||||
|
item_uri = buffer.get_selected_item_id()
|
||||||
|
if not item_uri:
|
||||||
|
output.speak(_("No item selected to unlike."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
like_uri = None
|
||||||
|
# Check viewer state from message_cache first, then panel's internal viewer_states
|
||||||
|
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
|
||||||
|
cached_post = buffer.session.message_cache[item_uri]
|
||||||
|
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
|
||||||
|
like_uri = cached_post["viewer"].get("like")
|
||||||
|
elif hasattr(cached_post, "viewer") and cached_post.viewer: # SDK model
|
||||||
|
like_uri = cached_post.viewer.like
|
||||||
|
|
||||||
|
if not like_uri and hasattr(buffer, "get_item_viewer_state"): # Fallback to panel's state if any
|
||||||
|
like_uri = buffer.get_item_viewer_state(item_uri, "like_uri")
|
||||||
|
|
||||||
|
if not like_uri:
|
||||||
|
output.speak(_("Could not find the original like record for this post, or it's already unliked."), True)
|
||||||
|
logger.warning(f"Attempted to unlike post {item_uri} but its like_uri was not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
social_handler = self.get_handler(buffer.session.KIND)
|
||||||
|
async def _unlike():
|
||||||
|
result = await social_handler.unlike_item(buffer.session, like_uri)
|
||||||
|
wx.CallAfter(output.speak, result["message"], True)
|
||||||
|
if result.get("status") == "success":
|
||||||
|
if hasattr(buffer, "store_item_viewer_state"):
|
||||||
|
wx.CallAfter(buffer.store_item_viewer_state, item_uri, "like_uri", None)
|
||||||
|
# Also update the item in message_cache
|
||||||
|
if buffer.session and hasattr(buffer.session, "message_cache") and item_uri in buffer.session.message_cache:
|
||||||
|
cached_post = buffer.session.message_cache[item_uri]
|
||||||
|
if isinstance(cached_post, dict) and isinstance(cached_post.get("viewer"), dict):
|
||||||
|
cached_post["viewer"]["like"] = None
|
||||||
|
elif hasattr(cached_post, "viewer") and cached_post.viewer:
|
||||||
|
cached_post.viewer.like = None
|
||||||
|
asyncio.create_task(_unlike())
|
||||||
|
|
||||||
|
|
||||||
def toggle_like(self, *args, **kwargs):
|
def toggle_like(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
@@ -601,6 +944,8 @@ class Controller(object):
|
|||||||
|
|
||||||
def buffer_changed(self, *args, **kwargs):
|
def buffer_changed(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
|
if buffer is None:
|
||||||
|
return
|
||||||
old_account = self.current_account
|
old_account = self.current_account
|
||||||
new_account = buffer.account
|
new_account = buffer.account
|
||||||
if new_account != old_account:
|
if new_account != old_account:
|
||||||
@@ -621,18 +966,81 @@ class Controller(object):
|
|||||||
self.view.check_menuitem("autoread", autoread)
|
self.view.check_menuitem("autoread", autoread)
|
||||||
|
|
||||||
def update_menus(self, handler):
|
def update_menus(self, handler):
|
||||||
|
# Initialize storage for hidden menu items if not present
|
||||||
|
if not hasattr(self, "_hidden_menu_items"):
|
||||||
|
self._hidden_menu_items = {}
|
||||||
|
if not hasattr(self, "_original_menu_labels"):
|
||||||
|
self._original_menu_labels = {}
|
||||||
|
|
||||||
if hasattr(handler, "menus"):
|
if hasattr(handler, "menus"):
|
||||||
for m in list(handler.menus.keys()):
|
for m in list(handler.menus.keys()):
|
||||||
if hasattr(self.view, m):
|
if hasattr(self.view, m):
|
||||||
menu_item = getattr(self.view, m)
|
menu_item = getattr(self.view, m)
|
||||||
if handler.menus[m] == None:
|
# Store original label on first encounter
|
||||||
|
if m not in self._original_menu_labels:
|
||||||
|
self._original_menu_labels[m] = menu_item.GetItemLabel()
|
||||||
|
|
||||||
|
if handler.menus[m] == "HIDE":
|
||||||
|
# Actually hide the menu item by removing it from parent menu
|
||||||
|
if m not in self._hidden_menu_items:
|
||||||
|
try:
|
||||||
|
parent_menu = menu_item.GetMenu()
|
||||||
|
if parent_menu:
|
||||||
|
# Store menu item info for restoration
|
||||||
|
position = self._find_menu_item_position(parent_menu, menu_item)
|
||||||
|
item_id = menu_item.GetId()
|
||||||
|
# Remove returns the removed item - store that reference
|
||||||
|
removed_item = parent_menu.Remove(item_id)
|
||||||
|
if removed_item:
|
||||||
|
self._hidden_menu_items[m] = {
|
||||||
|
"menu": parent_menu,
|
||||||
|
"item": removed_item,
|
||||||
|
"position": position
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error hiding menu item {m}: {e}")
|
||||||
|
elif handler.menus[m] == None:
|
||||||
|
# Restore if hidden, then disable
|
||||||
|
self._restore_menu_item(m)
|
||||||
menu_item.Enable(False)
|
menu_item.Enable(False)
|
||||||
else:
|
else:
|
||||||
|
# Restore if hidden, then enable with new label
|
||||||
|
self._restore_menu_item(m)
|
||||||
menu_item.Enable(True)
|
menu_item.Enable(True)
|
||||||
menu_item.SetItemLabel(handler.menus[m])
|
menu_item.SetItemLabel(handler.menus[m])
|
||||||
if hasattr(handler, "item_menu"):
|
if hasattr(handler, "item_menu"):
|
||||||
self.view.menubar.SetMenuLabel(1, handler.item_menu)
|
self.view.menubar.SetMenuLabel(1, handler.item_menu)
|
||||||
|
|
||||||
|
def _find_menu_item_position(self, menu, item):
|
||||||
|
"""Find the position of a menu item within its parent menu."""
|
||||||
|
for i, menu_item in enumerate(menu.GetMenuItems()):
|
||||||
|
if menu_item.GetId() == item.GetId():
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def _restore_menu_item(self, name):
|
||||||
|
"""Restore a previously hidden menu item."""
|
||||||
|
if not hasattr(self, "_hidden_menu_items"):
|
||||||
|
return
|
||||||
|
if name not in self._hidden_menu_items:
|
||||||
|
return
|
||||||
|
info = self._hidden_menu_items[name]
|
||||||
|
parent_menu = info["menu"]
|
||||||
|
item = info["item"]
|
||||||
|
position = info["position"]
|
||||||
|
try:
|
||||||
|
# Re-insert at original position
|
||||||
|
if position >= 0 and position < parent_menu.GetMenuItemCount():
|
||||||
|
parent_menu.Insert(position, item)
|
||||||
|
else:
|
||||||
|
parent_menu.Append(item)
|
||||||
|
# Restore original label if available
|
||||||
|
if hasattr(self, "_original_menu_labels") and name in self._original_menu_labels:
|
||||||
|
item.SetItemLabel(self._original_menu_labels[name])
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error restoring menu item {name}: {e}")
|
||||||
|
del self._hidden_menu_items[name]
|
||||||
|
|
||||||
def fix_wrong_buffer(self):
|
def fix_wrong_buffer(self):
|
||||||
buf = self.get_best_buffer()
|
buf = self.get_best_buffer()
|
||||||
if buf == None:
|
if buf == None:
|
||||||
@@ -721,6 +1129,9 @@ class Controller(object):
|
|||||||
output.speak(msg, True)
|
output.speak(msg, True)
|
||||||
|
|
||||||
def next_account(self, *args, **kwargs):
|
def next_account(self, *args, **kwargs):
|
||||||
|
if not self.accounts:
|
||||||
|
output.speak(_("No accounts available."), True)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
index = self.accounts.index(self.current_account)
|
index = self.accounts.index(self.current_account)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -733,11 +1144,11 @@ class Controller(object):
|
|||||||
self.current_account = account
|
self.current_account = account
|
||||||
buffer_object = self.get_first_buffer(account)
|
buffer_object = self.get_first_buffer(account)
|
||||||
if buffer_object == None:
|
if buffer_object == None:
|
||||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||||
return
|
return
|
||||||
buff = self.view.search(buffer_object.name, account)
|
buff = self.view.search(buffer_object.name, account)
|
||||||
if buff == None:
|
if buff == None:
|
||||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||||
return
|
return
|
||||||
self.view.change_buffer(buff)
|
self.view.change_buffer(buff)
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
@@ -749,6 +1160,9 @@ class Controller(object):
|
|||||||
output.speak(msg, True)
|
output.speak(msg, True)
|
||||||
|
|
||||||
def previous_account(self, *args, **kwargs):
|
def previous_account(self, *args, **kwargs):
|
||||||
|
if not self.accounts:
|
||||||
|
output.speak(_("No accounts available."), True)
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
index = self.accounts.index(self.current_account)
|
index = self.accounts.index(self.current_account)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -761,11 +1175,11 @@ class Controller(object):
|
|||||||
self.current_account = account
|
self.current_account = account
|
||||||
buffer_object = self.get_first_buffer(account)
|
buffer_object = self.get_first_buffer(account)
|
||||||
if buffer_object == None:
|
if buffer_object == None:
|
||||||
output.speak(_(u"{0}: This account is not logged into Twitter.").format(account), True)
|
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||||
return
|
return
|
||||||
buff = self.view.search(buffer_object.name, account)
|
buff = self.view.search(buffer_object.name, account)
|
||||||
if buff == None:
|
if buff == None:
|
||||||
output.speak(_(u"{0}: This account is not logged into twitter.").format(account), True)
|
output.speak(_(u"{0}: This account is not logged in.").format(account), True)
|
||||||
return
|
return
|
||||||
self.view.change_buffer(buff)
|
self.view.change_buffer(buff)
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
@@ -846,7 +1260,14 @@ class Controller(object):
|
|||||||
|
|
||||||
def register_invisible_keyboard_shorcuts(self, keymap):
|
def register_invisible_keyboard_shorcuts(self, keymap):
|
||||||
if config.changed_keymap:
|
if config.changed_keymap:
|
||||||
commonMessageDialogs.changed_keymap()
|
build_number = sys.getwindowsversion().build
|
||||||
|
if build_number > 22000:
|
||||||
|
system = "Windows 11"
|
||||||
|
keystroke_editor_shortcut = "Control+Win+Alt+K"
|
||||||
|
else:
|
||||||
|
system = "Windows 10"
|
||||||
|
keystroke_editor_shortcut = "Win+Alt+K"
|
||||||
|
commonMessageDialogs.changed_keymap(system, keystroke_editor_shortcut)
|
||||||
# Make sure we pass a keymap without undefined keystrokes.
|
# Make sure we pass a keymap without undefined keystrokes.
|
||||||
new_keymap = {key: keymap[key] for key in keymap.keys() if keymap[key] != ""}
|
new_keymap = {key: keymap[key] for key in keymap.keys() if keymap[key] != ""}
|
||||||
self.keyboard_handler = WXKeyboardHandler(self.view)
|
self.keyboard_handler = WXKeyboardHandler(self.view)
|
||||||
@@ -1004,14 +1425,125 @@ class Controller(object):
|
|||||||
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
|
log.exception("Error %s starting buffer %s on account %s, with args %r and kwargs %r." % (str(err), i.name, i.account, i.args, i.kwargs))
|
||||||
|
|
||||||
def update_buffer(self, *args, **kwargs):
|
def update_buffer(self, *args, **kwargs):
|
||||||
bf = self.get_current_buffer()
|
"""Handles the 'Update buffer' menu command to fetch newest items."""
|
||||||
if not hasattr(bf, "start_stream"):
|
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||||
output.speak(_(u"Unable to update this buffer."))
|
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||||
|
output.speak(_(u"No active session for this buffer."), True)
|
||||||
return
|
return
|
||||||
output.speak(_(u"Updating buffer..."))
|
|
||||||
n = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
output.speak(_(u"Updating buffer..."), True)
|
||||||
if n != None:
|
session = bf.session
|
||||||
output.speak(_(u"{0} items retrieved").format(n,))
|
|
||||||
|
output.speak(_(u"Updating buffer..."), True)
|
||||||
|
session = bf.session
|
||||||
|
|
||||||
|
import threading
|
||||||
|
is_blueski = (getattr(session, "KIND", None) == "blueski" or getattr(session, "type", None) == "blueski")
|
||||||
|
|
||||||
|
def do_update_sync():
|
||||||
|
new_ids = []
|
||||||
|
try:
|
||||||
|
if is_blueski:
|
||||||
|
if hasattr(bf, "start_stream"):
|
||||||
|
count = bf.start_stream(mandatory=True)
|
||||||
|
if count: new_ids = [str(x) for x in range(count)]
|
||||||
|
else:
|
||||||
|
wx.CallAfter(output.speak, _(u"This buffer type cannot be updated."), True)
|
||||||
|
return
|
||||||
|
else: # Generic fallback for other sessions
|
||||||
|
# If they are async, this might be tricky in a thread without a loop
|
||||||
|
# But most old sessions in TWBlue are sync (using threads)
|
||||||
|
if hasattr(bf, "start_stream"):
|
||||||
|
count = bf.start_stream(mandatory=True, avoid_autoreading=True)
|
||||||
|
if count: new_ids = [str(x) for x in range(count)]
|
||||||
|
else:
|
||||||
|
wx.CallAfter(output.speak, _(u"Unable to update this buffer."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generic feedback
|
||||||
|
if bf.type in ["home_timeline", "user_timeline", "notifications", "mentions"]:
|
||||||
|
wx.CallAfter(output.speak, _("{0} new items.").format(len(new_ids)), True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Error updating buffer %s", bf.name)
|
||||||
|
wx.CallAfter(output.speak, _("An error occurred while updating the buffer."), True)
|
||||||
|
|
||||||
|
if is_blueski:
|
||||||
|
threading.Thread(target=do_update_sync).start()
|
||||||
|
else:
|
||||||
|
# Original async logic for others if needed, but likely they are sync too.
|
||||||
|
# Assuming TWBlue architecture is mostly thread-based for legacy sessions.
|
||||||
|
# If we have an async loop running, we could use it for async-capable sessions.
|
||||||
|
# For safety, let's use the thread approach generally if we are not sure about the loop state.
|
||||||
|
threading.Thread(target=do_update_sync).start()
|
||||||
|
|
||||||
|
|
||||||
|
def get_more_items(self, *args, **kwargs):
|
||||||
|
"""Handles 'Load previous items' menu command."""
|
||||||
|
bf = self.get_current_buffer() # bf is the buffer panel instance
|
||||||
|
if not bf or not hasattr(bf, "session") or not bf.session:
|
||||||
|
output.speak(_(u"No active session for this buffer."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
session = bf.session
|
||||||
|
# The buffer panel (bf) needs to store its own cursor for pagination of older items
|
||||||
|
# e.g., bf.pagination_cursor or bf.older_items_cursor
|
||||||
|
# This cursor should be set by the result of previous fetch_..._timeline(new_only=False) calls.
|
||||||
|
|
||||||
|
# For Blueski, session methods like fetch_home_timeline store their own cursor (e.g., session.home_timeline_cursor)
|
||||||
|
# The panel (bf) itself should manage its own cursor for "load more"
|
||||||
|
|
||||||
|
current_cursor = None
|
||||||
|
can_load_more_natively = False
|
||||||
|
|
||||||
|
if getattr(session, "KIND", None) == "blueski":
|
||||||
|
if hasattr(bf, "load_more_posts"): # For BlueskiUserTimelinePanel & BlueskiHomeTimelinePanel
|
||||||
|
can_load_more_natively = True
|
||||||
|
if hasattr(bf, "load_more_posts"):
|
||||||
|
can_load_more_natively = True
|
||||||
|
elif hasattr(bf, "load_more_users"):
|
||||||
|
can_load_more_natively = True
|
||||||
|
elif bf.type == "notifications" and hasattr(bf, "load_more_notifications"): # Check for specific load_more
|
||||||
|
can_load_more_natively = True
|
||||||
|
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"): # Fallback for notifications to refresh
|
||||||
|
# If load_more_notifications doesn't exist, 'Load More' will just refresh.
|
||||||
|
can_load_more_natively = True # It will call refresh_notifications via the final 'else'
|
||||||
|
else:
|
||||||
|
if hasattr(bf, "get_more_items"):
|
||||||
|
return bf.get_more_items()
|
||||||
|
else:
|
||||||
|
output.speak(_(u"This buffer does not support loading more items in this way."), True)
|
||||||
|
return
|
||||||
|
else: # For other non-Blueski session types
|
||||||
|
if hasattr(bf, "get_more_items"):
|
||||||
|
return bf.get_more_items()
|
||||||
|
else:
|
||||||
|
output.speak(_(u"This buffer does not support loading more items."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
output.speak(_(u"Loading more items..."), True)
|
||||||
|
|
||||||
|
async def do_load_more():
|
||||||
|
try:
|
||||||
|
if session.KIND == "blueski":
|
||||||
|
if hasattr(bf, "load_more_posts"):
|
||||||
|
await bf.load_more_posts(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||||
|
elif hasattr(bf, "load_more_users"):
|
||||||
|
await bf.load_more_users(limit=config.app["app-settings"].get("items_per_request", 30))
|
||||||
|
elif bf.type == "notifications" and hasattr(bf, "refresh_notifications"):
|
||||||
|
# This will re-fetch recent, not older. A true "load_more_notifications(cursor=...)" is needed for that.
|
||||||
|
wx.CallAfter(output.speak, _("Refreshing notifications..."), True)
|
||||||
|
await bf.refresh_notifications(limit=config.app["app-settings"].get("items_per_request", 20))
|
||||||
|
# Feedback is handled by panel methods for consistency
|
||||||
|
|
||||||
|
except NotificationError as e:
|
||||||
|
wx.CallAfter(output.speak, str(e), True)
|
||||||
|
except Exception as e_general:
|
||||||
|
logger.error(f"Error loading more items for buffer {bf.name}: {e_general}", exc_info=True)
|
||||||
|
output.speak(_("An error occurred while loading more items."), True)
|
||||||
|
|
||||||
|
wx.CallAfter(asyncio.create_task, do_load_more())
|
||||||
|
|
||||||
|
|
||||||
def buffer_title_changed(self, buffer):
|
def buffer_title_changed(self, buffer):
|
||||||
if buffer.name.endswith("-timeline"):
|
if buffer.name.endswith("-timeline"):
|
||||||
@@ -1078,10 +1610,37 @@ class Controller(object):
|
|||||||
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
|
# if "direct_messages" not in buffer.session.settings["other_buffers"]["muted_buffers"]:
|
||||||
# self.notify(buffer.session, sound_to_play)
|
# self.notify(buffer.session, sound_to_play)
|
||||||
|
|
||||||
def mastodon_error_post(self, name, reply_to, visibility, posts):
|
def blueski_new_item(self, item, session_name, _buffers):
|
||||||
|
"""Handle new items from Bluesky polling."""
|
||||||
|
sound_to_play = None
|
||||||
|
for buff in _buffers:
|
||||||
|
buffer = self.search_buffer(buff, session_name)
|
||||||
|
if buffer is None or buffer.session.get_name() != session_name:
|
||||||
|
continue
|
||||||
|
if hasattr(buffer, "add_new_item"):
|
||||||
|
buffer.add_new_item(item)
|
||||||
|
# Determine sound to play
|
||||||
|
if buff == "notifications":
|
||||||
|
sound_to_play = "notification_received.ogg"
|
||||||
|
elif buff == "home_timeline":
|
||||||
|
sound_to_play = "tweet_received.ogg"
|
||||||
|
elif "timeline" in buff:
|
||||||
|
sound_to_play = "tweet_timeline.ogg"
|
||||||
|
else:
|
||||||
|
sound_to_play = None
|
||||||
|
# Play sound if buffer is not muted
|
||||||
|
if sound_to_play is not None:
|
||||||
|
try:
|
||||||
|
muted = buffer.session.settings["other_buffers"].get("muted_buffers", [])
|
||||||
|
if buff not in muted:
|
||||||
|
self.notify(buffer.session, sound_to_play)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def mastodon_error_post(self, name, reply_to, visibility, posts, language):
|
||||||
home = self.search_buffer("home_timeline", name)
|
home = self.search_buffer("home_timeline", name)
|
||||||
if home != None:
|
if home != None:
|
||||||
wx.CallAfter(home.post_from_error, visibility=visibility, reply_to=reply_to, data=posts)
|
wx.CallAfter(home.post_from_error, visibility=visibility, reply_to=reply_to, data=posts, lang=language)
|
||||||
|
|
||||||
def change_buffer_title(self, name, buffer, title):
|
def change_buffer_title(self, name, buffer, title):
|
||||||
buffer_index = self.view.search(buffer, name)
|
buffer_index = self.view.search(buffer, name)
|
||||||
@@ -1104,21 +1663,57 @@ class Controller(object):
|
|||||||
def user_details(self, *args):
|
def user_details(self, *args):
|
||||||
"""Displays a user's profile."""
|
"""Displays a user's profile."""
|
||||||
log.debug("Showing user profile...")
|
log.debug("Showing user profile...")
|
||||||
buffer = self.get_best_buffer()
|
buffer = self.get_current_buffer() # Use current buffer to get context if item is selected
|
||||||
|
if not buffer or not buffer.session:
|
||||||
|
buffer = self.get_best_buffer() # Fallback if current buffer has no session
|
||||||
|
|
||||||
|
if not buffer or not buffer.session:
|
||||||
|
output.speak(_("No active session to view user details."), True)
|
||||||
|
return
|
||||||
|
|
||||||
handler = self.get_handler(type=buffer.session.type)
|
handler = self.get_handler(type=buffer.session.type)
|
||||||
if handler and hasattr(handler, 'user_details'):
|
if handler and hasattr(handler, 'user_details'):
|
||||||
handler.user_details(buffer)
|
# The handler's user_details method is responsible for extracting context
|
||||||
|
# (e.g., selected user) from the buffer and displaying the profile.
|
||||||
|
# For Blueski, handler.user_details calls the ShowUserProfileDialog.
|
||||||
|
result = handler.user_details(buffer)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
call_threaded(asyncio.run, result)
|
||||||
|
else:
|
||||||
|
output.speak(_("This session type does not support viewing user details in this way."), True)
|
||||||
|
|
||||||
|
|
||||||
|
def openPostTimeline(self, *args, user=None): # "user" here is often the user object from selected item
|
||||||
|
"""Opens selected user's posts timeline. Renamed to open_user_timeline in handlers for clarity."""
|
||||||
|
current_buffer = self.get_current_buffer()
|
||||||
|
if not current_buffer or not current_buffer.session:
|
||||||
|
current_buffer = self.get_best_buffer()
|
||||||
|
|
||||||
|
if not current_buffer or not current_buffer.session:
|
||||||
|
output.speak(_("No active session available."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
session_to_use = current_buffer.session
|
||||||
|
handler = self.get_handler(type=session_to_use.type)
|
||||||
|
|
||||||
|
# Prefer the new standardized 'open_user_timeline'
|
||||||
|
if hasattr(handler, 'open_user_timeline'):
|
||||||
|
user_payload = user # Use passed 'user' if available
|
||||||
|
if user_payload is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||||
|
author_details = current_buffer.get_selected_item_author_details()
|
||||||
|
if author_details:
|
||||||
|
user_payload = author_details
|
||||||
|
|
||||||
|
result = handler.open_user_timeline(main_controller=self, session=session_to_use, user_payload=user_payload)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
call_threaded(asyncio.run, result)
|
||||||
|
|
||||||
|
elif hasattr(handler, 'openPostTimeline'): # Fallback for older handler structure
|
||||||
|
# This path might not correctly pass main_controller if the old handler expects it differently
|
||||||
|
handler.openPostTimeline(self, current_buffer, user)
|
||||||
|
else:
|
||||||
|
output.speak(_("This session type does not support opening user timelines directly."), True)
|
||||||
|
|
||||||
def openPostTimeline(self, *args, user=None):
|
|
||||||
"""Opens selected user's posts timeline
|
|
||||||
Parameters:
|
|
||||||
args: Other argument. Useful when binding to widgets.
|
|
||||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
|
||||||
"""
|
|
||||||
buffer = self.get_best_buffer()
|
|
||||||
handler = self.get_handler(type=buffer.session.type)
|
|
||||||
if handler and hasattr(handler, 'openPostTimeline'):
|
|
||||||
handler.openPostTimeline(self, buffer, user)
|
|
||||||
|
|
||||||
def openFollowersTimeline(self, *args, user=None):
|
def openFollowersTimeline(self, *args, user=None):
|
||||||
"""Opens selected user's followers timeline
|
"""Opens selected user's followers timeline
|
||||||
@@ -1126,10 +1721,30 @@ class Controller(object):
|
|||||||
args: Other argument. Useful when binding to widgets.
|
args: Other argument. Useful when binding to widgets.
|
||||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||||
"""
|
"""
|
||||||
buffer = self.get_best_buffer()
|
current_buffer = self.get_current_buffer()
|
||||||
handler = self.get_handler(type=buffer.session.type)
|
if not current_buffer or not current_buffer.session:
|
||||||
if handler and hasattr(handler, 'openFollowersTimeline'):
|
current_buffer = self.get_best_buffer()
|
||||||
handler.openFollowersTimeline(self, buffer, user)
|
|
||||||
|
if not current_buffer or not current_buffer.session:
|
||||||
|
output.speak(_("No active session available."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
session_to_use = current_buffer.session
|
||||||
|
handler = self.get_handler(type=session_to_use.type)
|
||||||
|
|
||||||
|
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||||
|
author_details = current_buffer.get_selected_item_author_details()
|
||||||
|
if author_details: user = author_details
|
||||||
|
|
||||||
|
if handler and hasattr(handler, 'open_followers_timeline'):
|
||||||
|
result = handler.open_followers_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
call_threaded(asyncio.run, result)
|
||||||
|
elif handler and hasattr(handler, 'openFollowersTimeline'): # Fallback
|
||||||
|
handler.openFollowersTimeline(self, current_buffer, user)
|
||||||
|
else:
|
||||||
|
output.speak(_("This session type does not support opening followers list."), True)
|
||||||
|
|
||||||
|
|
||||||
def openFollowingTimeline(self, *args, user=None):
|
def openFollowingTimeline(self, *args, user=None):
|
||||||
"""Opens selected user's following timeline
|
"""Opens selected user's following timeline
|
||||||
@@ -1137,7 +1752,45 @@ class Controller(object):
|
|||||||
args: Other argument. Useful when binding to widgets.
|
args: Other argument. Useful when binding to widgets.
|
||||||
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
user: if specified, open this user timeline. It is currently mandatory, but could be optional when user selection is implemented in handler
|
||||||
"""
|
"""
|
||||||
|
current_buffer = self.get_current_buffer()
|
||||||
|
if not current_buffer or not current_buffer.session:
|
||||||
|
current_buffer = self.get_best_buffer()
|
||||||
|
|
||||||
|
if not current_buffer or not current_buffer.session:
|
||||||
|
output.speak(_("No active session available."), True)
|
||||||
|
return
|
||||||
|
|
||||||
|
session_to_use = current_buffer.session
|
||||||
|
handler = self.get_handler(type=session_to_use.type)
|
||||||
|
|
||||||
|
if user is None and hasattr(current_buffer, 'get_selected_item_author_details'):
|
||||||
|
author_details = current_buffer.get_selected_item_author_details()
|
||||||
|
if author_details: user = author_details
|
||||||
|
|
||||||
|
if handler and hasattr(handler, 'open_following_timeline'):
|
||||||
|
result = handler.open_following_timeline(main_controller=self, session=session_to_use, user_payload=user)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
call_threaded(asyncio.run, result)
|
||||||
|
elif handler and hasattr(handler, 'openFollowingTimeline'): # Fallback
|
||||||
|
handler.openFollowingTimeline(self, current_buffer, user)
|
||||||
|
else:
|
||||||
|
output.speak(_("This session type does not support opening following list."), True)
|
||||||
|
|
||||||
|
|
||||||
|
def community_timeline(self, *args, user=None): # user param seems unused here based on mastodon impl
|
||||||
buffer = self.get_best_buffer()
|
buffer = self.get_best_buffer()
|
||||||
handler = self.get_handler(type=buffer.session.type)
|
handler = self.get_handler(type=buffer.session.type)
|
||||||
if handler and hasattr(handler, 'openFollowingTimeline'):
|
if handler and hasattr(handler, 'community_timeline'):
|
||||||
handler.openFollowingTimeline(self, buffer, user)
|
handler.community_timeline(self, buffer)
|
||||||
|
|
||||||
|
def create_filter(self, *args, **kwargs):
|
||||||
|
buffer = self.get_best_buffer()
|
||||||
|
handler = self.get_handler(type=buffer.session.type)
|
||||||
|
if handler and hasattr(handler, 'create_filter'):
|
||||||
|
handler.create_filter(self, buffer)
|
||||||
|
|
||||||
|
def manage_filters(self, *args, **kwargs):
|
||||||
|
buffer = self.get_best_buffer()
|
||||||
|
handler = self.get_handler(type=buffer.session.type)
|
||||||
|
if handler and hasattr(handler, 'manage_filters'):
|
||||||
|
handler.manage_filters(self, buffer)
|
||||||
|
|||||||
1
src/controller/mastodon/filters/__init__.py
Normal file
1
src/controller/mastodon/filters/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
112
src/controller/mastodon/filters/create_filter.py
Normal file
112
src/controller/mastodon/filters/create_filter.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import widgetUtils
|
||||||
|
from wxUI.dialogs.mastodon.filters import create_filter as dialog
|
||||||
|
from mastodon import MastodonAPIError
|
||||||
|
|
||||||
|
class CreateFilterController(object):
|
||||||
|
def __init__(self, session, filter_data=None):
|
||||||
|
super(CreateFilterController, self).__init__()
|
||||||
|
self.session = session
|
||||||
|
self.filter_data = filter_data
|
||||||
|
self.dialog = dialog.CreateFilterDialog(parent=None)
|
||||||
|
if self.filter_data is not None:
|
||||||
|
self.keywords = self.filter_data.get("keywords")
|
||||||
|
self.load_filter_data()
|
||||||
|
else:
|
||||||
|
self.keywords = []
|
||||||
|
widgetUtils.connect_event(self.dialog.keyword_panel.add_button, widgetUtils.BUTTON_PRESSED, self.on_add_keyword)
|
||||||
|
widgetUtils.connect_event(self.dialog.keyword_panel.remove_button, widgetUtils.BUTTON_PRESSED, self.on_remove_keyword)
|
||||||
|
|
||||||
|
def on_add_keyword(self, event):
|
||||||
|
""" Adds a keyword to the list. """
|
||||||
|
keyword = self.dialog.keyword_panel.keyword_text.GetValue().strip()
|
||||||
|
whole_word = self.dialog.keyword_panel.whole_word_checkbox.GetValue()
|
||||||
|
if keyword:
|
||||||
|
for idx, kw in enumerate(self.keywords):
|
||||||
|
if kw['keyword'] == keyword:
|
||||||
|
return
|
||||||
|
keyword_data = {
|
||||||
|
'keyword': keyword,
|
||||||
|
'whole_word': whole_word
|
||||||
|
}
|
||||||
|
self.keywords.append(keyword_data)
|
||||||
|
self.dialog.keyword_panel.add_keyword(keyword, whole_word)
|
||||||
|
|
||||||
|
def on_remove_keyword(self, event):
|
||||||
|
removed = self.dialog.keyword_panel.remove_keyword()
|
||||||
|
if removed is not None:
|
||||||
|
self.keywords.pop(removed)
|
||||||
|
|
||||||
|
def get_expires_in_seconds(self, selection, value):
|
||||||
|
if selection == 0:
|
||||||
|
return None
|
||||||
|
if selection == 1:
|
||||||
|
return value * 3600
|
||||||
|
elif selection == 2:
|
||||||
|
return value * 86400
|
||||||
|
elif selection == 3:
|
||||||
|
return value * 604800
|
||||||
|
elif selection == 4:
|
||||||
|
return value * 2592000
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_expires_in(self, seconds):
|
||||||
|
if seconds is None:
|
||||||
|
self.dialog.expiration_choice.SetSelection(0)
|
||||||
|
self.dialog.expiration_value.Enable(False)
|
||||||
|
return
|
||||||
|
if seconds % 2592000 == 0 and seconds >= 2592000:
|
||||||
|
self.dialog.expiration_choice.SetSelection(4)
|
||||||
|
self.dialog.expiration_value.SetValue(seconds // 2592000)
|
||||||
|
elif seconds % 604800 == 0 and seconds >= 604800:
|
||||||
|
self.dialog.expiration_choice.SetSelection(3)
|
||||||
|
self.dialog.expiration_value.SetValue(seconds // 604800)
|
||||||
|
elif seconds % 86400 == 0 and seconds >= 86400:
|
||||||
|
self.dialog.expiration_choice.SetSelection(2)
|
||||||
|
self.dialog.expiration_value.SetValue(seconds // 86400)
|
||||||
|
else:
|
||||||
|
self.dialog.expiration_choice.SetSelection(1)
|
||||||
|
self.dialog.expiration_value.SetValue(max(1, seconds // 3600))
|
||||||
|
self.dialog.expiration_value.Enable(True)
|
||||||
|
|
||||||
|
def load_filter_data(self):
|
||||||
|
if 'title' in self.filter_data:
|
||||||
|
self.dialog.name_ctrl.SetValue(self.filter_data['title'])
|
||||||
|
self.dialog.SetTitle(_("Update Filter: {}").format(self.filter_data['title']))
|
||||||
|
if 'context' in self.filter_data:
|
||||||
|
for context in self.filter_data['context']:
|
||||||
|
if context in self.dialog.context_checkboxes:
|
||||||
|
self.dialog.context_checkboxes[context].SetValue(True)
|
||||||
|
if 'filter_action' in self.filter_data:
|
||||||
|
action_index = self.dialog.actions.index(self.filter_data['filter_action']) if self.filter_data['filter_action'] in self.dialog.actions else 0
|
||||||
|
self.dialog.action_choice.SetSelection(action_index)
|
||||||
|
if 'expires_in' in self.filter_data:
|
||||||
|
self.set_expires_in(self.filter_data['expires_in'])
|
||||||
|
print(self.filter_data)
|
||||||
|
if 'keywords' in self.filter_data:
|
||||||
|
self.keywords = self.filter_data['keywords']
|
||||||
|
self.dialog.keyword_panel.set_keywords(self.filter_data['keywords'])
|
||||||
|
|
||||||
|
def get_filter_data(self):
|
||||||
|
filter_data = {
|
||||||
|
'title': self.dialog.name_ctrl.GetValue(),
|
||||||
|
'context': [],
|
||||||
|
'filter_action': self.dialog.actions[self.dialog.action_choice.GetSelection()],
|
||||||
|
'expires_in': self.get_expires_in_seconds(selection=self.dialog.expiration_choice.GetSelection(), value=self.dialog.expiration_value.GetValue()),
|
||||||
|
'keywords_attributes': self.keywords
|
||||||
|
}
|
||||||
|
for context, checkbox in self.dialog.context_checkboxes.items():
|
||||||
|
if checkbox.GetValue():
|
||||||
|
filter_data['context'].append(context)
|
||||||
|
return filter_data
|
||||||
|
|
||||||
|
def get_response(self):
|
||||||
|
response = self.dialog.ShowModal()
|
||||||
|
if response == widgetUtils.OK:
|
||||||
|
filter_data = self.get_filter_data()
|
||||||
|
if self.filter_data == None:
|
||||||
|
result = self.session.api.create_filter_v2(**filter_data)
|
||||||
|
else:
|
||||||
|
result = self.session.api.update_filter_v2(filter_id=self.filter_data['id'], **filter_data)
|
||||||
|
return result
|
||||||
|
return None
|
||||||
99
src/controller/mastodon/filters/manage_filters.py
Normal file
99
src/controller/mastodon/filters/manage_filters.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import datetime
|
||||||
|
import wx
|
||||||
|
import widgetUtils
|
||||||
|
from wxUI import commonMessageDialogs
|
||||||
|
from wxUI.dialogs.mastodon.filters import manage_filters as dialog
|
||||||
|
from . import create_filter
|
||||||
|
from mastodon import MastodonError
|
||||||
|
|
||||||
|
class ManageFiltersController(object):
|
||||||
|
def __init__(self, session):
|
||||||
|
super(ManageFiltersController, self).__init__()
|
||||||
|
self.session = session
|
||||||
|
self.selected_filter_idx = -1
|
||||||
|
self.error_loading = False
|
||||||
|
self.dialog = dialog.ManageFiltersDialog(parent=None)
|
||||||
|
self.dialog.filter_list.Bind(wx.EVT_LIST_ITEM_SELECTED, self.on_filter_selected)
|
||||||
|
self.dialog.filter_list.Bind(wx.EVT_LIST_ITEM_DESELECTED, self.on_filter_deselected)
|
||||||
|
widgetUtils.connect_event(self.dialog.add_button, wx.EVT_BUTTON, self.on_add_filter)
|
||||||
|
widgetUtils.connect_event(self.dialog.edit_button, wx.EVT_BUTTON, self.on_edit_filter)
|
||||||
|
widgetUtils.connect_event(self.dialog.remove_button, wx.EVT_BUTTON, self.on_remove_filter)
|
||||||
|
self.load_filter_data()
|
||||||
|
|
||||||
|
def on_filter_selected(self, event):
|
||||||
|
"""Handle filter selection event."""
|
||||||
|
self.selected_filter_idx = event.GetIndex()
|
||||||
|
self.dialog.edit_button.Enable()
|
||||||
|
self.dialog.remove_button.Enable()
|
||||||
|
|
||||||
|
def on_filter_deselected(self, event):
|
||||||
|
"""Handle filter deselection event."""
|
||||||
|
self.selected_filter_idx = -1
|
||||||
|
self.dialog.edit_button.Disable()
|
||||||
|
self.dialog.remove_button.Disable()
|
||||||
|
|
||||||
|
def get_selected_filter_id(self):
|
||||||
|
"""Get the ID of the currently selected filter."""
|
||||||
|
if self.selected_filter_idx != -1:
|
||||||
|
return self.dialog.filter_list.GetItemData(self.selected_filter_idx)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_filter_data(self):
|
||||||
|
try:
|
||||||
|
filters = self.session.api.filters_v2()
|
||||||
|
self.dialog.filter_list.DeleteAllItems()
|
||||||
|
self.on_filter_deselected(None)
|
||||||
|
for i, filter_obj in enumerate(filters):
|
||||||
|
index = self.dialog.filter_list.InsertItem(i, filter_obj.title)
|
||||||
|
keyword_count = len(filter_obj.keywords)
|
||||||
|
self.dialog.filter_list.SetItem(index, 1, str(keyword_count))
|
||||||
|
contexts = ", ".join(filter_obj.context)
|
||||||
|
self.dialog.filter_list.SetItem(index, 2, contexts)
|
||||||
|
self.dialog.filter_list.SetItem(index, 3, filter_obj.filter_action)
|
||||||
|
if filter_obj.expires_at:
|
||||||
|
expiry_str = filter_obj.expires_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
else:
|
||||||
|
expiry_str = _("Never")
|
||||||
|
self.dialog.filter_list.SetItem(index, 4, expiry_str)
|
||||||
|
self.dialog.filter_list.SetItemData(index, int(filter_obj.id) if isinstance(filter_obj.id, (int, str)) else 0)
|
||||||
|
except MastodonError as e:
|
||||||
|
commonMessageDialogs.error_loading_filters()
|
||||||
|
self.error_loading = True
|
||||||
|
|
||||||
|
def on_add_filter(self, *args, **kwargs):
|
||||||
|
filterController = create_filter.CreateFilterController(self.session)
|
||||||
|
try:
|
||||||
|
filter = filterController.get_response()
|
||||||
|
self.load_filter_data()
|
||||||
|
except MastodonError as error:
|
||||||
|
commonMessageDialogs.error_adding_filter()
|
||||||
|
return self.on_add_filter()
|
||||||
|
|
||||||
|
def on_edit_filter(self, *args, **kwargs):
|
||||||
|
filter_id = self.get_selected_filter_id()
|
||||||
|
if filter_id == None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
filter_data = self.session.api.filter_v2(filter_id)
|
||||||
|
filterController = create_filter.CreateFilterController(self.session, filter_data=filter_data)
|
||||||
|
filterController.get_response()
|
||||||
|
self.load_filter_data()
|
||||||
|
except MastodonError as error:
|
||||||
|
commonMessageDialogs.error_adding_filter()
|
||||||
|
|
||||||
|
def on_remove_filter(self, *args, **kwargs):
|
||||||
|
filter_id = self.get_selected_filter_id()
|
||||||
|
if filter_id == None:
|
||||||
|
return
|
||||||
|
dlg = commonMessageDialogs.remove_filter()
|
||||||
|
if dlg == widgetUtils.NO:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.session.api.delete_filter_v2(filter_id)
|
||||||
|
self.load_filter_data()
|
||||||
|
except MastodonError as error:
|
||||||
|
commonMessageDialogs.error_removing_filter()
|
||||||
|
|
||||||
|
def get_response(self):
|
||||||
|
return self.dialog.ShowModal() == wx.ID_OK
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import wx
|
import wx
|
||||||
import logging
|
import logging
|
||||||
|
import mastodon
|
||||||
import output
|
import output
|
||||||
|
from mastodon import MastodonError
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from mysc import restart
|
from mysc import restart
|
||||||
from mysc.thread_utils import call_threaded
|
from mysc.thread_utils import call_threaded
|
||||||
@@ -10,9 +12,10 @@ from wxUI.dialogs.mastodon import dialogs
|
|||||||
from wxUI.dialogs import userAliasDialogs
|
from wxUI.dialogs import userAliasDialogs
|
||||||
from wxUI import commonMessageDialogs
|
from wxUI import commonMessageDialogs
|
||||||
from wxUI.dialogs.mastodon import updateProfile as update_profile_dialogs
|
from wxUI.dialogs.mastodon import updateProfile as update_profile_dialogs
|
||||||
from wxUI.dialogs.mastodon import showUserProfile
|
from wxUI.dialogs.mastodon import showUserProfile, communityTimeline
|
||||||
from sessions.mastodon.utils import html_filter
|
from sessions.mastodon.utils import html_filter
|
||||||
from . import userActions, settings
|
from . import userActions, settings
|
||||||
|
from .filters import create_filter, manage_filters
|
||||||
|
|
||||||
log = logging.getLogger("controller.mastodon.handler")
|
log = logging.getLogger("controller.mastodon.handler")
|
||||||
|
|
||||||
@@ -32,6 +35,7 @@ class Handler(object):
|
|||||||
compose=_("&Post"),
|
compose=_("&Post"),
|
||||||
reply=_("Re&ply"),
|
reply=_("Re&ply"),
|
||||||
share=_("&Boost"),
|
share=_("&Boost"),
|
||||||
|
quote=_("&Quote"),
|
||||||
fav=_("&Add to favorites"),
|
fav=_("&Add to favorites"),
|
||||||
unfav=_("Remove from favorites"),
|
unfav=_("Remove from favorites"),
|
||||||
view=_("&Show post"),
|
view=_("&Show post"),
|
||||||
@@ -45,12 +49,12 @@ class Handler(object):
|
|||||||
addAlias=_("Add a&lias"),
|
addAlias=_("Add a&lias"),
|
||||||
addToList=None,
|
addToList=None,
|
||||||
removeFromList=None,
|
removeFromList=None,
|
||||||
details=_("Show user profile"),
|
details=_("S&how user profile"),
|
||||||
favs=None,
|
favs=None,
|
||||||
# In buffer Menu.
|
# In buffer Menu.
|
||||||
trends=None,
|
community_timeline =_("Create c&ommunity timeline"),
|
||||||
filter=None,
|
filter=_("Create a &filter"),
|
||||||
manage_filters=None
|
manage_filters=_("&Manage filters")
|
||||||
)
|
)
|
||||||
# Name for the "tweet" menu in the menu bar.
|
# Name for the "tweet" menu in the menu bar.
|
||||||
self.item_menu = _("&Post")
|
self.item_menu = _("&Post")
|
||||||
@@ -89,6 +93,8 @@ class Handler(object):
|
|||||||
pub.sendMessage("createBuffer", buffer_type="UserBuffer", session_type=session.type, buffer_title=_("Blocked users"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_user", function="blocks", name="blocked", sessionObject=session, account=name))
|
pub.sendMessage("createBuffer", buffer_type="UserBuffer", session_type=session.type, buffer_title=_("Blocked users"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_user", function="blocks", name="blocked", sessionObject=session, account=name))
|
||||||
elif i == 'notifications':
|
elif i == 'notifications':
|
||||||
pub.sendMessage("createBuffer", buffer_type="NotificationsBuffer", session_type=session.type, buffer_title=_("Notifications"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_notification", function="notifications", name="notifications", sessionObject=session, account=name))
|
pub.sendMessage("createBuffer", buffer_type="NotificationsBuffer", session_type=session.type, buffer_title=_("Notifications"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, compose_func="compose_notification", function="notifications", name="notifications", sessionObject=session, account=name))
|
||||||
|
elif i == 'announcements':
|
||||||
|
pub.sendMessage("createBuffer", buffer_type="AnnouncementsBuffer", session_type=session.type, buffer_title=_("Announcements"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, function="announcements", name="announcements", sessionObject=session, account=name, sound="new_event.ogg"))
|
||||||
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Timelines"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="timelines", account=name))
|
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Timelines"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="timelines", account=name))
|
||||||
timelines_position =controller.view.search("timelines", name)
|
timelines_position =controller.view.search("timelines", name)
|
||||||
for i in session.settings["other_buffers"]["timelines"]:
|
for i in session.settings["other_buffers"]["timelines"]:
|
||||||
@@ -102,9 +108,16 @@ class Handler(object):
|
|||||||
# for i in session.settings["other_buffers"]["lists"]:
|
# for i in session.settings["other_buffers"]["lists"]:
|
||||||
# pub.sendMessage("createBuffer", buffer_type="ListBuffer", session_type=session.type, buffer_title=_(u"List for {}").format(i), parent_tab=lists_position, start=False, kwargs=dict(parent=controller.view.nb, function="list_timeline", name="%s-list" % (i,), sessionObject=session, name, bufferType=None, sound="list_tweet.ogg", list_id=utils.find_list(i, session.db["lists"]), include_ext_alt_text=True, tweet_mode="extended"))
|
# pub.sendMessage("createBuffer", buffer_type="ListBuffer", session_type=session.type, buffer_title=_(u"List for {}").format(i), parent_tab=lists_position, start=False, kwargs=dict(parent=controller.view.nb, function="list_timeline", name="%s-list" % (i,), sessionObject=session, name, bufferType=None, sound="list_tweet.ogg", list_id=utils.find_list(i, session.db["lists"]), include_ext_alt_text=True, tweet_mode="extended"))
|
||||||
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Searches"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="searches", account=name))
|
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Searches"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="searches", account=name))
|
||||||
searches_position =controller.view.search("searches", session.db["user_name"])
|
searches_position =controller.view.search("searches", name)
|
||||||
for term in session.settings["other_buffers"]["post_searches"]:
|
for term in session.settings["other_buffers"]["post_searches"]:
|
||||||
pub.sendMessage("createBuffer", buffer_type="SearchBuffer", session_type=session.type, buffer_title=_("Search for {}").format(term), parent_tab=searches_position, start=True, kwargs=dict(parent=controller.view.nb, compose_func="compose_post", function="search", name="%s-searchterm" % (term,), sessionObject=session, account=session.get_name(), sound="search_updated.ogg", q=term, result_type="statuses"))
|
pub.sendMessage("createBuffer", buffer_type="SearchBuffer", session_type=session.type, buffer_title=_("Search for {}").format(term), parent_tab=searches_position, start=True, kwargs=dict(parent=controller.view.nb, compose_func="compose_post", function="search", name="%s-searchterm" % (term,), sessionObject=session, account=session.get_name(), sound="search_updated.ogg", q=term, result_type="statuses"))
|
||||||
|
pub.sendMessage("createBuffer", buffer_type="EmptyBuffer", session_type="base", buffer_title=_("Communities"), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="communities", account=name))
|
||||||
|
communities_position =controller.view.search("communities", name)
|
||||||
|
for community in session.settings["other_buffers"]["communities"]:
|
||||||
|
bufftype = _("Local") if community.split("@")[0] == "local" else _("federated")
|
||||||
|
community_name = community.split("@")[1].replace("https://", "")
|
||||||
|
title = _(f"{bufftype} timeline for {community_name}")
|
||||||
|
pub.sendMessage("createBuffer", buffer_type="CommunityBuffer", session_type=session.type, buffer_title=title, parent_tab=communities_position, start=True, kwargs=dict(parent=controller.view.nb, function="timeline", compose_func="compose_post", name=community, sessionObject=session, community_url=community.split("@")[1], account=session.get_name(), sound="search_updated.ogg", timeline=community.split("@")[0]))
|
||||||
# for i in session.settings["other_buffers"]["trending_topic_buffers"]:
|
# for i in session.settings["other_buffers"]["trending_topic_buffers"]:
|
||||||
# pub.sendMessage("createBuffer", buffer_type="TrendsBuffer", session_type=session.type, buffer_title=_("Trending topics for %s") % (i), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="%s_tt" % (i,), sessionObject=session, name, trendsFor=i, sound="trends_updated.ogg"))
|
# pub.sendMessage("createBuffer", buffer_type="TrendsBuffer", session_type=session.type, buffer_title=_("Trending topics for %s") % (i), parent_tab=root_position, start=False, kwargs=dict(parent=controller.view.nb, name="%s_tt" % (i,), sessionObject=session, name, trendsFor=i, sound="trends_updated.ogg"))
|
||||||
|
|
||||||
@@ -121,6 +134,11 @@ class Handler(object):
|
|||||||
pub.sendMessage("buffer-title-changed", buffer=buffer)
|
pub.sendMessage("buffer-title-changed", buffer=buffer)
|
||||||
|
|
||||||
def open_conversation(self, controller, buffer):
|
def open_conversation(self, controller, buffer):
|
||||||
|
# detect if we are in a community buffer.
|
||||||
|
# Community buffers are special because we'll need to retrieve the object locally at first.
|
||||||
|
if hasattr(buffer, "community_url"):
|
||||||
|
post = buffer.get_item_from_instance()
|
||||||
|
else:
|
||||||
post = buffer.get_item()
|
post = buffer.get_item()
|
||||||
if post.reblog != None:
|
if post.reblog != None:
|
||||||
post = post.reblog
|
post = post.reblog
|
||||||
@@ -130,6 +148,10 @@ class Handler(object):
|
|||||||
def follow(self, buffer):
|
def follow(self, buffer):
|
||||||
if not hasattr(buffer, "get_item"):
|
if not hasattr(buffer, "get_item"):
|
||||||
return
|
return
|
||||||
|
# Community buffers are special because we'll need to retrieve the object locally at first.
|
||||||
|
if hasattr(buffer, "community_url"):
|
||||||
|
item = buffer.get_item_from_instance()
|
||||||
|
else:
|
||||||
item = buffer.get_item()
|
item = buffer.get_item()
|
||||||
if buffer.type == "user":
|
if buffer.type == "user":
|
||||||
users = [item.acct]
|
users = [item.acct]
|
||||||
@@ -151,7 +173,11 @@ class Handler(object):
|
|||||||
users.insert(0, status.reblog.account.acct)
|
users.insert(0, status.reblog.account.acct)
|
||||||
else:
|
else:
|
||||||
users = [user.acct for user in status.mentions if user.id != buffer.session.db["user_id"]]
|
users = [user.acct for user in status.mentions if user.id != buffer.session.db["user_id"]]
|
||||||
if item.account.acct not in users:
|
if hasattr(item, "account"):
|
||||||
|
acct = item.account.acct
|
||||||
|
else:
|
||||||
|
acct = item.acct
|
||||||
|
if acct not in users:
|
||||||
users.insert(0, item.account.acct)
|
users.insert(0, item.account.acct)
|
||||||
u = userActions.userActions(buffer.session, users)
|
u = userActions.userActions(buffer.session, users)
|
||||||
|
|
||||||
@@ -178,6 +204,9 @@ class Handler(object):
|
|||||||
def open_timeline(self, controller, buffer):
|
def open_timeline(self, controller, buffer):
|
||||||
if not hasattr(buffer, "get_item"):
|
if not hasattr(buffer, "get_item"):
|
||||||
return
|
return
|
||||||
|
if hasattr(buffer, "community_url"):
|
||||||
|
item = buffer.get_item_from_instance()
|
||||||
|
else:
|
||||||
item = buffer.get_item()
|
item = buffer.get_item()
|
||||||
if buffer.type == "user":
|
if buffer.type == "user":
|
||||||
users = [item.acct]
|
users = [item.acct]
|
||||||
@@ -355,3 +384,42 @@ class Handler(object):
|
|||||||
user = buffer.session.api.account(selectedUser[-1])
|
user = buffer.session.api.account(selectedUser[-1])
|
||||||
dlg = showUserProfile.ShowUserProfile(user)
|
dlg = showUserProfile.ShowUserProfile(user)
|
||||||
dlg.ShowModal()
|
dlg.ShowModal()
|
||||||
|
|
||||||
|
def community_timeline(self, controller, buffer):
|
||||||
|
dlg = communityTimeline.CommunityTimeline()
|
||||||
|
if dlg.ShowModal() != wx.ID_OK:
|
||||||
|
return
|
||||||
|
url = dlg.url.GetValue()
|
||||||
|
bufftype = dlg.get_action()
|
||||||
|
local_api = mastodon.Mastodon(api_base_url=url)
|
||||||
|
try:
|
||||||
|
instance = local_api.instance()
|
||||||
|
except MastodonError:
|
||||||
|
commonMessageDialogs.invalid_instance()
|
||||||
|
return
|
||||||
|
if bufftype == "local":
|
||||||
|
title = _(f"Local timeline for {url.replace('https://', '')}")
|
||||||
|
else:
|
||||||
|
title = _(f"Federated timeline for {url}")
|
||||||
|
bufftype = "public"
|
||||||
|
dlg.Destroy()
|
||||||
|
tl_info = f"{bufftype}@{url}"
|
||||||
|
if tl_info in buffer.session.settings["other_buffers"]["communities"]:
|
||||||
|
return # buffer already exists.
|
||||||
|
buffer.session.settings["other_buffers"]["communities"].append(tl_info)
|
||||||
|
buffer.session.settings.write()
|
||||||
|
communities_position =controller.view.search("communities", buffer.session.get_name())
|
||||||
|
pub.sendMessage("createBuffer", buffer_type="CommunityBuffer", session_type=buffer.session.type, buffer_title=title, parent_tab=communities_position, start=True, kwargs=dict(parent=controller.view.nb, function="timeline", name=tl_info, sessionObject=buffer.session, account=buffer.session.get_name(), sound="tweet_timeline.ogg", community_url=url, timeline=bufftype))
|
||||||
|
|
||||||
|
def create_filter(self, controller, buffer):
|
||||||
|
filterController = create_filter.CreateFilterController(buffer.session)
|
||||||
|
try:
|
||||||
|
filter = filterController.get_response()
|
||||||
|
except MastodonError as error:
|
||||||
|
log.exception("Error adding filter.")
|
||||||
|
commonMessageDialogs.error_adding_filter()
|
||||||
|
return self.create_filter(controller=controller, buffer=buffer)
|
||||||
|
|
||||||
|
def manage_filters(self, controller, buffer):
|
||||||
|
manageFiltersController = manage_filters.ManageFiltersController(buffer.session)
|
||||||
|
manageFiltersController.get_response()
|
||||||
@@ -2,14 +2,20 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import wx
|
import wx
|
||||||
|
import logging
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
import config
|
import config
|
||||||
import output
|
import output
|
||||||
|
import languageHandler
|
||||||
from twitter_text import parse_tweet, config
|
from twitter_text import parse_tweet, config
|
||||||
|
from mastodon import MastodonError
|
||||||
from controller import messages
|
from controller import messages
|
||||||
from sessions.mastodon import templates
|
from sessions.mastodon import templates, utils
|
||||||
from wxUI.dialogs.mastodon import postDialogs
|
from wxUI.dialogs.mastodon import postDialogs
|
||||||
from extra.autocompletionUsers import completion
|
from extra.autocompletionUsers import completion
|
||||||
|
from . import userList
|
||||||
|
|
||||||
|
log = logging.getLogger("controller.mastodon.messages")
|
||||||
|
|
||||||
def character_count(post_text, post_cw, character_limit=500):
|
def character_count(post_text, post_cw, character_limit=500):
|
||||||
# We will use text for counting character limit only.
|
# We will use text for counting character limit only.
|
||||||
@@ -30,9 +36,12 @@ class post(messages.basicMessage):
|
|||||||
self.max = session.char_limit
|
self.max = session.char_limit
|
||||||
self.title = title
|
self.title = title
|
||||||
self.session = session
|
self.session = session
|
||||||
self.message = postDialogs.Post(caption=caption, text=text, *args, **kwargs)
|
langs = self.session.supported_languages
|
||||||
|
display_langs = [l.name for l in langs]
|
||||||
|
self.message = postDialogs.Post(caption=caption, text=text, languages=display_langs, *args, **kwargs)
|
||||||
self.message.SetTitle(title)
|
self.message.SetTitle(title)
|
||||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||||
|
self.set_language(self.session.default_language)
|
||||||
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
widgetUtils.connect_event(self.message.spellcheck, widgetUtils.BUTTON_PRESSED, self.spellcheck)
|
||||||
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
|
widgetUtils.connect_event(self.message.text, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||||
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
|
widgetUtils.connect_event(self.message.spoiler, widgetUtils.ENTERED_TEXT, self.text_processor)
|
||||||
@@ -56,6 +65,13 @@ class post(messages.basicMessage):
|
|||||||
postdata = dict(text=text, attachments=attachments, sensitive=self.message.sensitive.GetValue(), spoiler_text=None)
|
postdata = dict(text=text, attachments=attachments, sensitive=self.message.sensitive.GetValue(), spoiler_text=None)
|
||||||
if postdata.get("sensitive") == True:
|
if postdata.get("sensitive") == True:
|
||||||
postdata.update(spoiler_text=self.message.spoiler.GetValue())
|
postdata.update(spoiler_text=self.message.spoiler.GetValue())
|
||||||
|
|
||||||
|
# Check for scheduled post
|
||||||
|
if hasattr(self.message, 'get_scheduled_at'):
|
||||||
|
scheduled_at = self.message.get_scheduled_at()
|
||||||
|
if scheduled_at:
|
||||||
|
postdata['scheduled_at'] = scheduled_at
|
||||||
|
|
||||||
self.thread.append(postdata)
|
self.thread.append(postdata)
|
||||||
self.attachments = []
|
self.attachments = []
|
||||||
if update_gui:
|
if update_gui:
|
||||||
@@ -68,7 +84,21 @@ class post(messages.basicMessage):
|
|||||||
self.add_post(event=None, update_gui=False)
|
self.add_post(event=None, update_gui=False)
|
||||||
return self.thread
|
return self.thread
|
||||||
|
|
||||||
def set_post_data(self, visibility, data):
|
def set_language(self, language=None):
|
||||||
|
""" Attempt to set the default language for a post. """
|
||||||
|
# language can be provided in a post (replying or recovering from errors).
|
||||||
|
# Also it can be provided in user preferences (retrieved in the session).
|
||||||
|
# If no language is provided, let's fallback to TWBlue's user language.
|
||||||
|
if language != None:
|
||||||
|
language_code = language
|
||||||
|
else:
|
||||||
|
# Let's cut langcode_VARIANT to ISO-639 two letter code only.
|
||||||
|
language_code = languageHandler.curLang[:2]
|
||||||
|
for lang in self.session.supported_languages:
|
||||||
|
if lang.code == language_code:
|
||||||
|
self.message.language.SetStringSelection(lang.name)
|
||||||
|
|
||||||
|
def set_post_data(self, visibility, data, language):
|
||||||
if len(data) == 0:
|
if len(data) == 0:
|
||||||
return
|
return
|
||||||
if len(data) > 1:
|
if len(data) > 1:
|
||||||
@@ -85,6 +115,7 @@ class post(messages.basicMessage):
|
|||||||
self.message.on_sensitivity_changed()
|
self.message.on_sensitivity_changed()
|
||||||
for attachment in self.attachments:
|
for attachment in self.attachments:
|
||||||
self.message.add_item(item=[attachment["file"], attachment["type"], attachment["description"]])
|
self.message.add_item(item=[attachment["file"], attachment["type"], attachment["description"]])
|
||||||
|
self.set_language(language)
|
||||||
self.text_processor()
|
self.text_processor()
|
||||||
|
|
||||||
def text_processor(self, *args, **kwargs):
|
def text_processor(self, *args, **kwargs):
|
||||||
@@ -229,15 +260,123 @@ class post(messages.basicMessage):
|
|||||||
visibility_settings = ["public", "unlisted", "private", "direct"]
|
visibility_settings = ["public", "unlisted", "private", "direct"]
|
||||||
return visibility_settings[self.message.visibility.GetSelection()]
|
return visibility_settings[self.message.visibility.GetSelection()]
|
||||||
|
|
||||||
|
def get_language(self):
|
||||||
|
langs = self.session.supported_languages
|
||||||
|
lang = self.message.language.GetSelection()
|
||||||
|
if lang >= 0:
|
||||||
|
return langs[lang].code
|
||||||
|
return None
|
||||||
|
|
||||||
def set_visibility(self, setting):
|
def set_visibility(self, setting):
|
||||||
visibility_settings = ["public", "unlisted", "private", "direct"]
|
visibility_settings = ["public", "unlisted", "private", "direct"]
|
||||||
visibility_setting = visibility_settings.index(setting)
|
visibility_setting = visibility_settings.index(setting)
|
||||||
self.message.visibility.SetSelection(setting)
|
self.message.visibility.SetSelection(setting)
|
||||||
|
|
||||||
|
class editPost(post):
|
||||||
|
def __init__(self, session, item, title, caption, *args, **kwargs):
|
||||||
|
""" Initialize edit dialog with existing post data.
|
||||||
|
|
||||||
|
Note: Per Mastodon API, visibility and language cannot be changed when editing.
|
||||||
|
These fields will be displayed but disabled in the UI.
|
||||||
|
"""
|
||||||
|
# Extract text from post
|
||||||
|
if item.reblog != None:
|
||||||
|
item = item.reblog
|
||||||
|
text = utils.html_filter(item.content)
|
||||||
|
# Initialize parent class
|
||||||
|
super(editPost, self).__init__(session, title, caption, text=text, *args, **kwargs)
|
||||||
|
# Store the post ID for editing
|
||||||
|
self.post_id = item.id
|
||||||
|
# Set visibility (read-only, cannot be changed)
|
||||||
|
visibility_settings = dict(public=0, unlisted=1, private=2, direct=3)
|
||||||
|
self.message.visibility.SetSelection(visibility_settings.get(item.visibility, 0))
|
||||||
|
self.message.visibility.Enable(False) # Disable as it cannot be edited
|
||||||
|
# Set language (read-only, cannot be changed)
|
||||||
|
if item.language:
|
||||||
|
self.set_language(item.language)
|
||||||
|
self.message.language.Enable(False) # Disable as it cannot be edited
|
||||||
|
# Set sensitive content and spoiler
|
||||||
|
if item.sensitive:
|
||||||
|
self.message.sensitive.SetValue(True)
|
||||||
|
if item.spoiler_text:
|
||||||
|
self.message.spoiler.ChangeValue(item.spoiler_text)
|
||||||
|
self.message.on_sensitivity_changed()
|
||||||
|
# Load existing poll (if any)
|
||||||
|
# Note: You cannot have both media and a poll, so check poll first
|
||||||
|
if hasattr(item, 'poll') and item.poll is not None:
|
||||||
|
log.debug("Loading existing poll for post {}".format(self.post_id))
|
||||||
|
poll = item.poll
|
||||||
|
# Extract poll options (just the text, not the votes)
|
||||||
|
poll_options = [option.title for option in poll.options]
|
||||||
|
# Calculate expires_in based on current time and expires_at
|
||||||
|
# For editing, we need to provide a new expiration time
|
||||||
|
# Since we can't get the original expires_in, use a default or let user configure
|
||||||
|
# For now, use 1 day (86400 seconds) as default
|
||||||
|
expires_in = 86400
|
||||||
|
if hasattr(poll, 'expires_at') and poll.expires_at and not poll.expired:
|
||||||
|
# Calculate remaining time if poll hasn't expired
|
||||||
|
from dateutil import parser as date_parser
|
||||||
|
import datetime
|
||||||
|
try:
|
||||||
|
expires_at = poll.expires_at
|
||||||
|
if isinstance(expires_at, str):
|
||||||
|
expires_at = date_parser.parse(expires_at)
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
remaining = (expires_at - now).total_seconds()
|
||||||
|
if remaining > 0:
|
||||||
|
expires_in = int(remaining)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Could not calculate poll expiration: {}".format(e))
|
||||||
|
|
||||||
|
poll_info = {
|
||||||
|
"type": "poll",
|
||||||
|
"file": "",
|
||||||
|
"description": _("Poll with {} options").format(len(poll_options)),
|
||||||
|
"options": poll_options,
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"multiple": poll.multiple if hasattr(poll, 'multiple') else False,
|
||||||
|
"hide_totals": poll.voters_count == 0 if hasattr(poll, 'voters_count') else False
|
||||||
|
}
|
||||||
|
self.attachments.append(poll_info)
|
||||||
|
self.message.add_item(item=[poll_info["file"], poll_info["type"], poll_info["description"]])
|
||||||
|
log.debug("Loaded poll with {} options. WARNING: Editing will reset all votes!".format(len(poll_options)))
|
||||||
|
# Load existing media attachments (only if no poll)
|
||||||
|
elif hasattr(item, 'media_attachments'):
|
||||||
|
log.debug("Loading existing media attachments for post {}".format(self.post_id))
|
||||||
|
log.debug("Item has media_attachments attribute, count: {}".format(len(item.media_attachments)))
|
||||||
|
if len(item.media_attachments) > 0:
|
||||||
|
for media in item.media_attachments:
|
||||||
|
log.debug("Processing media: id={}, type={}, url={}".format(media.id, media.type, media.url))
|
||||||
|
media_info = {
|
||||||
|
"id": media.id, # Keep the existing media ID
|
||||||
|
"type": media.type,
|
||||||
|
"file": media.url, # URL of existing media
|
||||||
|
"description": media.description or ""
|
||||||
|
}
|
||||||
|
# Include focus point if available
|
||||||
|
if hasattr(media, 'meta') and media.meta and 'focus' in media.meta:
|
||||||
|
focus = media.meta['focus']
|
||||||
|
media_info["focus"] = (focus.get('x'), focus.get('y'))
|
||||||
|
log.debug("Added focus point: {}".format(media_info["focus"]))
|
||||||
|
self.attachments.append(media_info)
|
||||||
|
# Display in the attachment list
|
||||||
|
display_name = media.url.split('/')[-1]
|
||||||
|
log.debug("Adding item to UI: name={}, type={}, desc={}".format(display_name, media.type, media.description or ""))
|
||||||
|
self.message.add_item(item=[display_name, media.type, media.description or ""])
|
||||||
|
log.debug("Total attachments loaded: {}".format(len(self.attachments)))
|
||||||
|
else:
|
||||||
|
log.debug("media_attachments list is empty")
|
||||||
|
else:
|
||||||
|
log.debug("Item has no poll or media attachments")
|
||||||
|
# Update text processor to reflect the loaded content
|
||||||
|
self.text_processor()
|
||||||
|
|
||||||
class viewPost(post):
|
class viewPost(post):
|
||||||
def __init__(self, post, offset_hours=0, date="", item_url=""):
|
def __init__(self, session, post, offset_hours=0, date="", item_url=""):
|
||||||
|
self.session = session
|
||||||
if post.reblog != None:
|
if post.reblog != None:
|
||||||
post = post.reblog
|
post = post.reblog
|
||||||
|
self.post_id = post.id
|
||||||
author = post.account.display_name if post.account.display_name != "" else post.account.username
|
author = post.account.display_name if post.account.display_name != "" else post.account.username
|
||||||
title = _(u"Post from {}").format(author)
|
title = _(u"Post from {}").format(author)
|
||||||
image_description = templates.process_image_descriptions(post.media_attachments)
|
image_description = templates.process_image_descriptions(post.media_attachments)
|
||||||
@@ -254,6 +393,12 @@ class viewPost(post):
|
|||||||
else:
|
else:
|
||||||
source = source_obj.get("name")
|
source = source_obj.get("name")
|
||||||
self.message = postDialogs.viewPost(text=text, boosts_count=boost_count, favs_count=favs_count, source=source, date=date, privacy=privacy)
|
self.message = postDialogs.viewPost(text=text, boosts_count=boost_count, favs_count=favs_count, source=source, date=date, privacy=privacy)
|
||||||
|
participants = [post.account.id] + [account.id for account in post.mentions]
|
||||||
|
if self.session.db["user_id"] in participants:
|
||||||
|
self.message.mute.Enable(True)
|
||||||
|
if post.muted:
|
||||||
|
self.message.mute.SetLabel(_("Unmute conversation"))
|
||||||
|
widgetUtils.connect_event(self.message.mute, widgetUtils.BUTTON_PRESSED, self.mute_unmute)
|
||||||
self.message.SetTitle(title)
|
self.message.SetTitle(title)
|
||||||
if image_description != "":
|
if image_description != "":
|
||||||
self.message.image_description.Enable(True)
|
self.message.image_description.Enable(True)
|
||||||
@@ -264,12 +409,42 @@ class viewPost(post):
|
|||||||
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
|
widgetUtils.connect_event(self.message.share, widgetUtils.BUTTON_PRESSED, self.share)
|
||||||
self.item_url = item_url
|
self.item_url = item_url
|
||||||
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
widgetUtils.connect_event(self.message.translateButton, widgetUtils.BUTTON_PRESSED, self.translate)
|
||||||
|
widgetUtils.connect_event(self.message.boosts_button, widgetUtils.BUTTON_PRESSED, self.on_boosts)
|
||||||
|
widgetUtils.connect_event(self.message.favorites_button, widgetUtils.BUTTON_PRESSED, self.on_favorites)
|
||||||
self.message.ShowModal()
|
self.message.ShowModal()
|
||||||
|
|
||||||
# We won't need text_processor in this dialog, so let's avoid it.
|
# We won't need text_processor in this dialog, so let's avoid it.
|
||||||
def text_processor(self):
|
def text_processor(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def mute_unmute(self, *args, **kwargs):
|
||||||
|
post = self.session.api.status(self.post_id)
|
||||||
|
if post.muted == True:
|
||||||
|
action = "status_unmute"
|
||||||
|
new_label = _("Mute conversation")
|
||||||
|
msg = _("Conversation unmuted.")
|
||||||
|
else:
|
||||||
|
action = "status_mute"
|
||||||
|
new_label = _("Unmute conversation")
|
||||||
|
msg = _("Conversation muted.")
|
||||||
|
try:
|
||||||
|
getattr(self.session.api, action)(self.post_id)
|
||||||
|
self.message.mute.SetLabel(new_label)
|
||||||
|
output.speak(msg)
|
||||||
|
except MastodonError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def on_boosts(self, *args, **kwargs):
|
||||||
|
users = self.session.api.status_reblogged_by(self.post_id)
|
||||||
|
title = _("people who boosted this post")
|
||||||
|
user_list = userList.MastodonUserList(session=self.session, users=users, title=title)
|
||||||
|
|
||||||
|
def on_favorites(self, *args, **kwargs):
|
||||||
|
users = self.session.api.status_favourited_by(self.post_id)
|
||||||
|
title = _("people who favorited this post")
|
||||||
|
user_list = userList.MastodonUserList(session=self.session, users=users, title=title)
|
||||||
|
|
||||||
def share(self, *args, **kwargs):
|
def share(self, *args, **kwargs):
|
||||||
if hasattr(self, "item_url"):
|
if hasattr(self, "item_url"):
|
||||||
output.copy(self.item_url)
|
output.copy(self.item_url)
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ class accountSettingsController(globalSettingsController):
|
|||||||
post_template = self.config["templates"]["post"]
|
post_template = self.config["templates"]["post"]
|
||||||
conversation_template = self.config["templates"]["conversation"]
|
conversation_template = self.config["templates"]["conversation"]
|
||||||
person_template = self.config["templates"]["person"]
|
person_template = self.config["templates"]["person"]
|
||||||
self.dialog.create_templates(post_template=post_template, conversation_template=conversation_template, person_template=person_template)
|
announcement_template = self.config.get("templates", {}).get("announcement", "$text. Published $published_at. $read")
|
||||||
|
self.dialog.create_templates(post_template=post_template, conversation_template=conversation_template, person_template=person_template, announcement_template=announcement_template)
|
||||||
widgetUtils.connect_event(self.dialog.templates.post, widgetUtils.BUTTON_PRESSED, self.edit_post_template)
|
widgetUtils.connect_event(self.dialog.templates.post, widgetUtils.BUTTON_PRESSED, self.edit_post_template)
|
||||||
widgetUtils.connect_event(self.dialog.templates.conversation, widgetUtils.BUTTON_PRESSED, self.edit_conversation_template)
|
widgetUtils.connect_event(self.dialog.templates.conversation, widgetUtils.BUTTON_PRESSED, self.edit_conversation_template)
|
||||||
widgetUtils.connect_event(self.dialog.templates.person, widgetUtils.BUTTON_PRESSED, self.edit_person_template)
|
widgetUtils.connect_event(self.dialog.templates.person, widgetUtils.BUTTON_PRESSED, self.edit_person_template)
|
||||||
|
widgetUtils.connect_event(self.dialog.templates.announcement, widgetUtils.BUTTON_PRESSED, self.edit_announcement_template)
|
||||||
self.dialog.create_other_buffers()
|
self.dialog.create_other_buffers()
|
||||||
buffer_values = self.get_buffers_list()
|
buffer_values = self.get_buffers_list()
|
||||||
self.dialog.buffers.insert_buffers(buffer_values)
|
self.dialog.buffers.insert_buffers(buffer_values)
|
||||||
@@ -109,6 +111,15 @@ class accountSettingsController(globalSettingsController):
|
|||||||
self.config.write()
|
self.config.write()
|
||||||
self.dialog.templates.person.SetLabel(_("Edit template for persons. Current template: {}").format(result))
|
self.dialog.templates.person.SetLabel(_("Edit template for persons. Current template: {}").format(result))
|
||||||
|
|
||||||
|
def edit_announcement_template(self, *args, **kwargs):
|
||||||
|
template = self.config.get("templates", {}).get("announcement", "$text. Published $published_at. $read")
|
||||||
|
control = EditTemplate(template=template, type="announcement")
|
||||||
|
result = control.run_dialog()
|
||||||
|
if result != "": # Template has been saved.
|
||||||
|
self.config["templates"]["announcement"] = result
|
||||||
|
self.config.write()
|
||||||
|
self.dialog.templates.announcement.SetLabel(_("Edit template for announcements. Current template: {}").format(result))
|
||||||
|
|
||||||
def save_configuration(self):
|
def save_configuration(self):
|
||||||
if self.config["general"]["relative_times"] != self.dialog.get_value("general", "relative_time"):
|
if self.config["general"]["relative_times"] != self.dialog.get_value("general", "relative_time"):
|
||||||
self.needs_restart = True
|
self.needs_restart = True
|
||||||
@@ -204,6 +215,7 @@ class accountSettingsController(globalSettingsController):
|
|||||||
all_buffers['blocked']=_("Blocked users")
|
all_buffers['blocked']=_("Blocked users")
|
||||||
all_buffers['muted']=_("Muted users")
|
all_buffers['muted']=_("Muted users")
|
||||||
all_buffers['notifications']=_("Notifications")
|
all_buffers['notifications']=_("Notifications")
|
||||||
|
all_buffers['announcements']=_("Announcements")
|
||||||
list_buffers = []
|
list_buffers = []
|
||||||
hidden_buffers=[]
|
hidden_buffers=[]
|
||||||
all_buffers_keys = list(all_buffers.keys())
|
all_buffers_keys = list(all_buffers.keys())
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import re
|
import re
|
||||||
import wx
|
import wx
|
||||||
from typing import List
|
from typing import List
|
||||||
from sessions.mastodon.templates import post_variables, conversation_variables, person_variables
|
from sessions.mastodon.templates import post_variables, conversation_variables, person_variables, announcement_variables
|
||||||
from wxUI.dialogs import templateDialogs
|
from wxUI.dialogs import templateDialogs
|
||||||
|
|
||||||
class EditTemplate(object):
|
class EditTemplate(object):
|
||||||
@@ -13,6 +13,8 @@ class EditTemplate(object):
|
|||||||
self.variables = post_variables
|
self.variables = post_variables
|
||||||
elif type == "conversation":
|
elif type == "conversation":
|
||||||
self.variables = conversation_variables
|
self.variables = conversation_variables
|
||||||
|
elif type == "announcement":
|
||||||
|
self.variables = announcement_variables
|
||||||
else:
|
else:
|
||||||
self.variables = person_variables
|
self.variables = person_variables
|
||||||
self.template: str = template
|
self.template: str = template
|
||||||
|
|||||||
25
src/controller/mastodon/userList.py
Normal file
25
src/controller/mastodon/userList.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from mastodon import MastodonError
|
||||||
|
from wxUI.dialogs.mastodon import showUserProfile
|
||||||
|
from controller.userList import UserListController
|
||||||
|
from . import userActions
|
||||||
|
|
||||||
|
class MastodonUserList(UserListController):
|
||||||
|
|
||||||
|
def process_users(self, users):
|
||||||
|
return [dict(id=user.id, display_name=f"{user.display_name} (@{user.acct})", acct=user.acct) for user in users]
|
||||||
|
|
||||||
|
def on_actions(self, *args, **kwargs):
|
||||||
|
user = self.dialog.user_list.GetSelection()
|
||||||
|
user_account = self.users[user]
|
||||||
|
u = userActions.userActions(self.session, [user_account.get("acct")])
|
||||||
|
|
||||||
|
def on_details(self, *args, **kwargs):
|
||||||
|
user = self.dialog.user_list.GetSelection()
|
||||||
|
user_id = self.users[user].get("id")
|
||||||
|
try:
|
||||||
|
user_object = self.session.api.account(user_id)
|
||||||
|
except MastodonError:
|
||||||
|
return
|
||||||
|
dlg = showUserProfile.ShowUserProfile(user_object)
|
||||||
|
dlg.ShowModal()
|
||||||
@@ -1,25 +1,21 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import widgetUtils
|
import widgetUtils
|
||||||
import output
|
import output
|
||||||
from extra import translator, SpellChecker
|
import config
|
||||||
|
from extra import SpellChecker
|
||||||
|
from extra.translator import TranslatorController
|
||||||
|
|
||||||
class basicMessage(object):
|
class basicMessage(object):
|
||||||
def translate(self, event=None):
|
def translate(self, event=None):
|
||||||
dlg = translator.gui.translateDialog()
|
t = TranslatorController(self.message.text.GetValue())
|
||||||
if dlg.get_response() == widgetUtils.OK:
|
if t.response == False:
|
||||||
text_to_translate = self.message.text.GetValue()
|
return
|
||||||
language_dict = translator.translator.available_languages()
|
msg = t.translate()
|
||||||
for k in language_dict:
|
|
||||||
if language_dict[k] == dlg.dest_lang.GetStringSelection():
|
|
||||||
dst = k
|
|
||||||
msg = translator.translator.translate(text=text_to_translate, target=dst)
|
|
||||||
self.message.text.ChangeValue(msg)
|
self.message.text.ChangeValue(msg)
|
||||||
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
self.message.text.SetInsertionPoint(len(self.message.text.GetValue()))
|
||||||
self.text_processor()
|
self.text_processor()
|
||||||
self.message.text.SetFocus()
|
self.message.text.SetFocus()
|
||||||
output.speak(_(u"Translated"))
|
output.speak(_(u"Translated"))
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
def text_processor(self, *args, **kwargs):
|
def text_processor(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import config
|
|||||||
import languageHandler
|
import languageHandler
|
||||||
import application
|
import application
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from mysc import autostart as autostart_windows
|
|
||||||
from wxUI.dialogs import configuration
|
from wxUI.dialogs import configuration
|
||||||
from wxUI import commonMessageDialogs
|
from wxUI import commonMessageDialogs
|
||||||
|
|
||||||
@@ -48,21 +47,16 @@ class globalSettingsController(object):
|
|||||||
self.dialog.create_general(langs,self.kmfriendlies)
|
self.dialog.create_general(langs,self.kmfriendlies)
|
||||||
self.dialog.general.language.SetSelection(id)
|
self.dialog.general.language.SetSelection(id)
|
||||||
self.dialog.general.km.SetSelection(self.kmid)
|
self.dialog.general.km.SetSelection(self.kmid)
|
||||||
if paths.mode == "installed":
|
|
||||||
self.dialog.set_value("general", "autostart", config.app["app-settings"]["autostart"])
|
|
||||||
else:
|
|
||||||
self.dialog.general.autostart.Enable(False)
|
|
||||||
self.dialog.set_value("general", "ask_at_exit", config.app["app-settings"]["ask_at_exit"])
|
self.dialog.set_value("general", "ask_at_exit", config.app["app-settings"]["ask_at_exit"])
|
||||||
self.dialog.set_value("general", "no_streaming", config.app["app-settings"]["no_streaming"])
|
self.dialog.set_value("general", "no_streaming", config.app["app-settings"]["no_streaming"])
|
||||||
self.dialog.set_value("general", "play_ready_sound", config.app["app-settings"]["play_ready_sound"])
|
self.dialog.set_value("general", "play_ready_sound", config.app["app-settings"]["play_ready_sound"])
|
||||||
self.dialog.set_value("general", "speak_ready_msg", config.app["app-settings"]["speak_ready_msg"])
|
self.dialog.set_value("general", "speak_ready_msg", config.app["app-settings"]["speak_ready_msg"])
|
||||||
self.dialog.set_value("general", "handle_longtweets", config.app["app-settings"]["handle_longtweets"])
|
self.dialog.set_value("general", "read_long_posts_in_gui", config.app["app-settings"]["read_long_posts_in_gui"])
|
||||||
self.dialog.set_value("general", "use_invisible_shorcuts", config.app["app-settings"]["use_invisible_keyboard_shorcuts"])
|
self.dialog.set_value("general", "use_invisible_shorcuts", config.app["app-settings"]["use_invisible_keyboard_shorcuts"])
|
||||||
self.dialog.set_value("general", "disable_sapi5", config.app["app-settings"]["voice_enabled"])
|
self.dialog.set_value("general", "disable_sapi5", config.app["app-settings"]["voice_enabled"])
|
||||||
self.dialog.set_value("general", "hide_gui", config.app["app-settings"]["hide_gui"])
|
self.dialog.set_value("general", "hide_gui", config.app["app-settings"]["hide_gui"])
|
||||||
self.dialog.set_value("general", "update_period", config.app["app-settings"]["update_period"])
|
self.dialog.set_value("general", "update_period", config.app["app-settings"]["update_period"])
|
||||||
self.dialog.set_value("general", "check_for_updates", config.app["app-settings"]["check_for_updates"])
|
self.dialog.set_value("general", "check_for_updates", config.app["app-settings"]["check_for_updates"])
|
||||||
self.dialog.set_value("general", "remember_mention_and_longtweet", config.app["app-settings"]["remember_mention_and_longtweet"])
|
|
||||||
proxyTypes = [_("System default"), _("HTTP"), _("SOCKS v4"), _("SOCKS v4 with DNS support"), _("SOCKS v5"), _("SOCKS v5 with DNS support")]
|
proxyTypes = [_("System default"), _("HTTP"), _("SOCKS v4"), _("SOCKS v4 with DNS support"), _("SOCKS v5"), _("SOCKS v5 with DNS support")]
|
||||||
self.dialog.create_proxy(proxyTypes)
|
self.dialog.create_proxy(proxyTypes)
|
||||||
try:
|
try:
|
||||||
@@ -73,7 +67,10 @@ class globalSettingsController(object):
|
|||||||
self.dialog.set_value("proxy", "port", config.app["proxy"]["port"])
|
self.dialog.set_value("proxy", "port", config.app["proxy"]["port"])
|
||||||
self.dialog.set_value("proxy", "user", config.app["proxy"]["user"])
|
self.dialog.set_value("proxy", "user", config.app["proxy"]["user"])
|
||||||
self.dialog.set_value("proxy", "password", config.app["proxy"]["password"])
|
self.dialog.set_value("proxy", "password", config.app["proxy"]["password"])
|
||||||
|
self.dialog.create_translator_panel()
|
||||||
|
self.dialog.set_value("translator_panel", "libre_api_url", config.app["translator"]["lt_api_url"])
|
||||||
|
self.dialog.set_value("translator_panel", "libre_api_key", config.app["translator"]["lt_api_key"])
|
||||||
|
self.dialog.set_value("translator_panel", "deepL_api_key", config.app["translator"]["deepl_api_key"])
|
||||||
self.dialog.realize()
|
self.dialog.realize()
|
||||||
self.response = self.dialog.get_response()
|
self.response = self.dialog.get_response()
|
||||||
|
|
||||||
@@ -89,9 +86,6 @@ class globalSettingsController(object):
|
|||||||
kmFile.close()
|
kmFile.close()
|
||||||
log.debug("Triggered app restart due to a keymap change.")
|
log.debug("Triggered app restart due to a keymap change.")
|
||||||
self.needs_restart = True
|
self.needs_restart = True
|
||||||
if config.app["app-settings"]["autostart"] != self.dialog.get_value("general", "autostart") and paths.mode == "installed":
|
|
||||||
config.app["app-settings"]["autostart"] = self.dialog.get_value("general", "autostart")
|
|
||||||
autostart_windows.setAutoStart(application.name, enable=self.dialog.get_value("general", "autostart"))
|
|
||||||
if config.app["app-settings"]["use_invisible_keyboard_shorcuts"] != self.dialog.get_value("general", "use_invisible_shorcuts"):
|
if config.app["app-settings"]["use_invisible_keyboard_shorcuts"] != self.dialog.get_value("general", "use_invisible_shorcuts"):
|
||||||
config.app["app-settings"]["use_invisible_keyboard_shorcuts"] = self.dialog.get_value("general", "use_invisible_shorcuts")
|
config.app["app-settings"]["use_invisible_keyboard_shorcuts"] = self.dialog.get_value("general", "use_invisible_shorcuts")
|
||||||
pub.sendMessage("invisible-shorcuts-changed", registered=self.dialog.get_value("general", "use_invisible_shorcuts"))
|
pub.sendMessage("invisible-shorcuts-changed", registered=self.dialog.get_value("general", "use_invisible_shorcuts"))
|
||||||
@@ -106,11 +100,10 @@ class globalSettingsController(object):
|
|||||||
config.app["app-settings"]["voice_enabled"] = self.dialog.get_value("general", "disable_sapi5")
|
config.app["app-settings"]["voice_enabled"] = self.dialog.get_value("general", "disable_sapi5")
|
||||||
config.app["app-settings"]["hide_gui"] = self.dialog.get_value("general", "hide_gui")
|
config.app["app-settings"]["hide_gui"] = self.dialog.get_value("general", "hide_gui")
|
||||||
config.app["app-settings"]["ask_at_exit"] = self.dialog.get_value("general", "ask_at_exit")
|
config.app["app-settings"]["ask_at_exit"] = self.dialog.get_value("general", "ask_at_exit")
|
||||||
config.app["app-settings"]["handle_longtweets"] = self.dialog.get_value("general", "handle_longtweets")
|
config.app["app-settings"]["read_long_posts_in_gui"] = self.dialog.get_value("general", "read_long_posts_in_gui")
|
||||||
config.app["app-settings"]["play_ready_sound"] = self.dialog.get_value("general", "play_ready_sound")
|
config.app["app-settings"]["play_ready_sound"] = self.dialog.get_value("general", "play_ready_sound")
|
||||||
config.app["app-settings"]["speak_ready_msg"] = self.dialog.get_value("general", "speak_ready_msg")
|
config.app["app-settings"]["speak_ready_msg"] = self.dialog.get_value("general", "speak_ready_msg")
|
||||||
config.app["app-settings"]["check_for_updates"] = self.dialog.get_value("general", "check_for_updates")
|
config.app["app-settings"]["check_for_updates"] = self.dialog.get_value("general", "check_for_updates")
|
||||||
config.app["app-settings"]["remember_mention_and_longtweet"] = self.dialog.get_value("general", "remember_mention_and_longtweet")
|
|
||||||
if config.app["proxy"]["type"]!=self.dialog.get_value("proxy", "type") or config.app["proxy"]["server"] != self.dialog.get_value("proxy", "server") or config.app["proxy"]["port"] != self.dialog.get_value("proxy", "port") or config.app["proxy"]["user"] != self.dialog.get_value("proxy", "user") or config.app["proxy"]["password"] != self.dialog.get_value("proxy", "password"):
|
if config.app["proxy"]["type"]!=self.dialog.get_value("proxy", "type") or config.app["proxy"]["server"] != self.dialog.get_value("proxy", "server") or config.app["proxy"]["port"] != self.dialog.get_value("proxy", "port") or config.app["proxy"]["user"] != self.dialog.get_value("proxy", "user") or config.app["proxy"]["password"] != self.dialog.get_value("proxy", "password"):
|
||||||
if self.is_started == True:
|
if self.is_started == True:
|
||||||
self.needs_restart = True
|
self.needs_restart = True
|
||||||
@@ -120,4 +113,7 @@ class globalSettingsController(object):
|
|||||||
config.app["proxy"]["port"] = self.dialog.get_value("proxy", "port")
|
config.app["proxy"]["port"] = self.dialog.get_value("proxy", "port")
|
||||||
config.app["proxy"]["user"] = self.dialog.get_value("proxy", "user")
|
config.app["proxy"]["user"] = self.dialog.get_value("proxy", "user")
|
||||||
config.app["proxy"]["password"] = self.dialog.get_value("proxy", "password")
|
config.app["proxy"]["password"] = self.dialog.get_value("proxy", "password")
|
||||||
|
config.app["translator"]["lt_api_url"] = self.dialog.get_value("translator_panel", "libre_api_url")
|
||||||
|
config.app["translator"]["lt_api_key"] = self.dialog.get_value("translator_panel", "libre_api_key")
|
||||||
|
config.app["translator"]["deepl_api_key"] = self.dialog.get_value("translator_panel", "deepL_api_key")
|
||||||
config.app.write()
|
config.app.write()
|
||||||
|
|||||||
23
src/controller/userList.py
Normal file
23
src/controller/userList.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import widgetUtils
|
||||||
|
from pubsub import pub
|
||||||
|
from wxUI.dialogs import userList
|
||||||
|
|
||||||
|
class UserListController(object):
|
||||||
|
def __init__(self, users, session, title):
|
||||||
|
super(UserListController, self).__init__()
|
||||||
|
self.session = session
|
||||||
|
self.users = self.process_users(users)
|
||||||
|
self.dialog = userList.UserListDialog(title=title, users=[user.get("display_name", user.get("acct")) for user in self.users])
|
||||||
|
widgetUtils.connect_event(self.dialog.actions_button, widgetUtils.BUTTON_PRESSED, self.on_actions)
|
||||||
|
widgetUtils.connect_event(self.dialog.details_button, widgetUtils.BUTTON_PRESSED, self.on_details)
|
||||||
|
self.dialog.ShowModal()
|
||||||
|
|
||||||
|
def process_users(self, users):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def on_actions(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_details(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
@@ -8,8 +8,8 @@ class autocompletionScanDialog(widgetUtils.BaseDialog):
|
|||||||
super(autocompletionScanDialog, self).__init__(parent=None, id=-1, title=_(u"Autocomplete users' settings"))
|
super(autocompletionScanDialog, self).__init__(parent=None, id=-1, title=_(u"Autocomplete users' settings"))
|
||||||
panel = wx.Panel(self)
|
panel = wx.Panel(self)
|
||||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
self.followers = wx.CheckBox(panel, -1, _("Add followers to database"))
|
self.followers = wx.CheckBox(panel, -1, _("Add &followers to database"))
|
||||||
self.friends = wx.CheckBox(panel, -1, _("Add following to database"))
|
self.friends = wx.CheckBox(panel, -1, _("Add f&ollowing to database"))
|
||||||
sizer.Add(self.followers, 0, wx.ALL, 5)
|
sizer.Add(self.followers, 0, wx.ALL, 5)
|
||||||
sizer.Add(self.friends, 0, wx.ALL, 5)
|
sizer.Add(self.friends, 0, wx.ALL, 5)
|
||||||
ok = wx.Button(panel, wx.ID_OK)
|
ok = wx.Button(panel, wx.ID_OK)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ class autocompletionManageDialog(widgetUtils.BaseDialog):
|
|||||||
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
|
self.users = widgets.list(panel, _(u"Username"), _(u"Name"), style=wx.LC_REPORT)
|
||||||
sizer.Add(label, 0, wx.ALL, 5)
|
sizer.Add(label, 0, wx.ALL, 5)
|
||||||
sizer.Add(self.users.list, 0, wx.ALL, 5)
|
sizer.Add(self.users.list, 0, wx.ALL, 5)
|
||||||
self.add = wx.Button(panel, -1, _(u"Add user"))
|
self.add = wx.Button(panel, -1, _(u"&Add user"))
|
||||||
self.remove = wx.Button(panel, -1, _(u"Remove user"))
|
self.remove = wx.Button(panel, -1, _(u"&Remove user"))
|
||||||
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
|
optionsBox = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
optionsBox.Add(self.add, 0, wx.ALL, 5)
|
optionsBox.Add(self.add, 0, wx.ALL, 5)
|
||||||
optionsBox.Add(self.remove, 0, wx.ALL, 5)
|
optionsBox.Add(self.remove, 0, wx.ALL, 5)
|
||||||
|
|||||||
@@ -1,5 +1,2 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import absolute_import
|
from .translator import TranslatorController
|
||||||
from __future__ import unicode_literals
|
|
||||||
from . import translator
|
|
||||||
from . import wx_ui as gui
|
|
||||||
|
|||||||
1
src/extra/translator/engines/__init__.py
Normal file
1
src/extra/translator/engines/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
14
src/extra/translator/engines/deep_l.py
Normal file
14
src/extra/translator/engines/deep_l.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import config
|
||||||
|
from deepl import Translator
|
||||||
|
|
||||||
|
def translate(text: str, target_language: str) -> str:
|
||||||
|
key = config.app["translator"]["deepl_api_key"]
|
||||||
|
t = Translator(key)
|
||||||
|
return t.translate_text(text, target_lang=target_language).text
|
||||||
|
|
||||||
|
def languages():
|
||||||
|
key = config.app["translator"]["deepl_api_key"]
|
||||||
|
t = Translator(key)
|
||||||
|
langs = t.get_target_languages()
|
||||||
|
return langs
|
||||||
45
src/extra/translator/engines/libre_translate.py
Normal file
45
src/extra/translator/engines/libre_translate.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
""" Modified Libretranslatepy module which adds an user agent for making requests against more instances. """
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict
|
||||||
|
from urllib import request, parse
|
||||||
|
from libretranslatepy import LibreTranslateAPI
|
||||||
|
|
||||||
|
class CustomLibreTranslateAPI(LibreTranslateAPI):
|
||||||
|
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||||
|
|
||||||
|
def _create_request(self, url: str, method: str, data: Dict[str, str]) -> request.Request:
|
||||||
|
url_params = parse.urlencode(data)
|
||||||
|
req = request.Request(url, method=method, data=url_params.encode())
|
||||||
|
req.add_header("User-Agent", self.USER_AGENT)
|
||||||
|
return req
|
||||||
|
|
||||||
|
def translate(self, q: str, source: str = "en", target: str = "es", timeout: int | None = None) -> Any:
|
||||||
|
url = self.url + "translate"
|
||||||
|
params: Dict[str, str] = {"q": q, "source": source, "target": target}
|
||||||
|
if self.api_key is not None:
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
req = self._create_request(url=url, method="POST", data=params)
|
||||||
|
response = request.urlopen(req, timeout=timeout)
|
||||||
|
response_str = response.read().decode()
|
||||||
|
return json.loads(response_str)["translatedText"]
|
||||||
|
|
||||||
|
def detect(self, q: str, timeout: int | None = None) -> Any:
|
||||||
|
url = self.url + "detect"
|
||||||
|
params: Dict[str, str] = {"q": q}
|
||||||
|
if self.api_key is not None:
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
req = self._create_request(url=url, method="POST", data=params)
|
||||||
|
response = request.urlopen(req, timeout=timeout)
|
||||||
|
response_str = response.read().decode()
|
||||||
|
return json.loads(response_str)
|
||||||
|
|
||||||
|
def languages(self, timeout: int | None = None) -> Any:
|
||||||
|
url = self.url + "languages"
|
||||||
|
params: Dict[str, str] = dict()
|
||||||
|
if self.api_key is not None:
|
||||||
|
params["api_key"] = self.api_key
|
||||||
|
req = self._create_request(url=url, method="GET", data=params)
|
||||||
|
response = request.urlopen(req, timeout=timeout)
|
||||||
|
response_str = response.read().decode()
|
||||||
|
return json.loads(response_str)
|
||||||
@@ -1,116 +1,58 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
import logging
|
||||||
from googletrans import Translator, LANGUAGES
|
import threading
|
||||||
|
import wx
|
||||||
|
import config
|
||||||
|
from pubsub import pub
|
||||||
|
from . engines import libre_translate, deep_l
|
||||||
|
from .wx_ui import translateDialog
|
||||||
|
|
||||||
log = logging.getLogger("extras.translator")
|
log = logging.getLogger("extras.translator")
|
||||||
|
|
||||||
# create a single translator instance
|
class TranslatorController(object):
|
||||||
# see https://github.com/ssut/py-googletrans/issues/234
|
def __init__(self, text):
|
||||||
t = None
|
super(TranslatorController, self).__init__()
|
||||||
|
self.text = text
|
||||||
|
self.languages = []
|
||||||
|
self.response = False
|
||||||
|
self.dialog = translateDialog()
|
||||||
|
pub.subscribe(self.on_engine_changed, "translator.engine_changed")
|
||||||
|
if config.app["translator"]["engine"] == "LibreTranslate":
|
||||||
|
self.dialog.engine_select.SetSelection(0)
|
||||||
|
elif config.app["translator"]["engine"] == "DeepL":
|
||||||
|
self.dialog.engine_select.SetSelection(1)
|
||||||
|
threading.Thread(target=self.load_languages).start()
|
||||||
|
if self.dialog.ShowModal() == wx.ID_OK:
|
||||||
|
self.response = True
|
||||||
|
for k in self.language_dict:
|
||||||
|
if self.language_dict[k] == self.dialog.dest_lang.GetStringSelection():
|
||||||
|
self.target_language= k
|
||||||
|
pub.unsubscribe(self.on_engine_changed, "translator.engine_changed")
|
||||||
|
|
||||||
def translate(text="", target="en"):
|
def load_languages(self):
|
||||||
global t
|
self.language_dict = self.get_languages()
|
||||||
log.debug("Received translation request for language %s, text=%s" % (target, text))
|
self.languages = [self.language_dict[k] for k in self.language_dict]
|
||||||
if t == None:
|
self.dialog.set_languages(self.languages)
|
||||||
t = Translator()
|
|
||||||
vars = dict(text=text, dest=target)
|
|
||||||
return t.translate(**vars).text
|
|
||||||
|
|
||||||
supported_langs = None
|
def on_engine_changed(self, engine):
|
||||||
|
config.app["translator"]["engine"] = engine
|
||||||
|
config.app.write()
|
||||||
|
threading.Thread(target=self.load_languages).start()
|
||||||
|
|
||||||
languages = {
|
def translate(self):
|
||||||
"af": _(u"Afrikaans"),
|
log.debug("Received translation request for language %s, text=%s" % (self.target_language, self.text))
|
||||||
"sq": _(u"Albanian"),
|
if config.app["translator"].get("engine") == "LibreTranslate":
|
||||||
"am": _(u"Amharic"),
|
translator = libre_translate.CustomLibreTranslateAPI(config.app["translator"]["lt_api_url"], config.app["translator"]["lt_api_key"])
|
||||||
"ar": _(u"Arabic"),
|
vars = dict(q=self.text, target=self.target_language)
|
||||||
"hy": _(u"Armenian"),
|
return translator.translate(**vars)
|
||||||
"az": _(u"Azerbaijani"),
|
elif config.app["translator"]["engine"] == "DeepL" and config.app["translator"]["deepl_api_key"] != "":
|
||||||
"eu": _(u"Basque"),
|
return deep_l.translate(text=self.text, target_language=self.target_language)
|
||||||
"be": _(u"Belarusian"),
|
|
||||||
"bn": _(u"Bengali"),
|
|
||||||
"bh": _(u"Bihari"),
|
|
||||||
"bg": _(u"Bulgarian"),
|
|
||||||
"my": _(u"Burmese"),
|
|
||||||
"ca": _(u"Catalan"),
|
|
||||||
"chr": _(u"Cherokee"),
|
|
||||||
"zh": _(u"Chinese"),
|
|
||||||
"zh-CN": _(u"Chinese_simplified"),
|
|
||||||
"zh-TW": _(u"Chinese_traditional"),
|
|
||||||
"hr": _(u"Croatian"),
|
|
||||||
"cs": _(u"Czech"),
|
|
||||||
"da": _(u"Danish"),
|
|
||||||
"dv": _(u"Dhivehi"),
|
|
||||||
"nl": _(u"Dutch"),
|
|
||||||
"en": _(u"English"),
|
|
||||||
"eo": _(u"Esperanto"),
|
|
||||||
"et": _(u"Estonian"),
|
|
||||||
"tl": _(u"Filipino"),
|
|
||||||
"fi": _(u"Finnish"),
|
|
||||||
"fr": _(u"French"),
|
|
||||||
"gl": _(u"Galician"),
|
|
||||||
"ka": _(u"Georgian"),
|
|
||||||
"de": _(u"German"),
|
|
||||||
"el": _(u"Greek"),
|
|
||||||
"gn": _(u"Guarani"),
|
|
||||||
"gu": _(u"Gujarati"),
|
|
||||||
"iw": _(u"Hebrew"),
|
|
||||||
"hi": _(u"Hindi"),
|
|
||||||
"hu": _(u"Hungarian"),
|
|
||||||
"is": _(u"Icelandic"),
|
|
||||||
"id": _(u"Indonesian"),
|
|
||||||
"iu": _(u"Inuktitut"),
|
|
||||||
"ga": _(u"Irish"),
|
|
||||||
"it": _(u"Italian"),
|
|
||||||
"ja": _(u"Japanese"),
|
|
||||||
"kn": _(u"Kannada"),
|
|
||||||
"kk": _(u"Kazakh"),
|
|
||||||
"km": _(u"Khmer"),
|
|
||||||
"ko": _(u"Korean"),
|
|
||||||
"ku": _(u"Kurdish"),
|
|
||||||
"ky": _(u"Kyrgyz"),
|
|
||||||
"lo": _(u"Laothian"),
|
|
||||||
"lv": _(u"Latvian"),
|
|
||||||
"lt": _(u"Lithuanian"),
|
|
||||||
"mk": _(u"Macedonian"),
|
|
||||||
"ms": _(u"Malay"),
|
|
||||||
"ml": _(u"Malayalam"),
|
|
||||||
"mt": _(u"Maltese"),
|
|
||||||
"mr": _(u"Marathi"),
|
|
||||||
"mn": _(u"Mongolian"),
|
|
||||||
"ne": _(u"Nepali"),
|
|
||||||
"no": _(u"Norwegian"),
|
|
||||||
"or": _(u"Oriya"),
|
|
||||||
"ps": _(u"Pashto"),
|
|
||||||
"fa": _(u"Persian"),
|
|
||||||
"pl": _(u"Polish"),
|
|
||||||
"pt": _(u"Portuguese"),
|
|
||||||
"pa": _(u"Punjabi"),
|
|
||||||
"ro": _(u"Romanian"),
|
|
||||||
"ru": _(u"Russian"),
|
|
||||||
"sa": _(u"Sanskrit"),
|
|
||||||
"sr": _(u"Serbian"),
|
|
||||||
"sd": _(u"Sindhi"),
|
|
||||||
"si": _(u"Sinhalese"),
|
|
||||||
"sk": _(u"Slovak"),
|
|
||||||
"sl": _(u"Slovenian"),
|
|
||||||
"es": _(u"Spanish"),
|
|
||||||
"sw": _(u"Swahili"),
|
|
||||||
"sv": _(u"Swedish"),
|
|
||||||
"tg": _(u"Tajik"),
|
|
||||||
"ta": _(u"Tamil"),
|
|
||||||
"tl": _(u"Tagalog"),
|
|
||||||
"te": _(u"Telugu"),
|
|
||||||
"th": _(u"Thai"),
|
|
||||||
"bo": _(u"Tibetan"),
|
|
||||||
"tr": _(u"Turkish"),
|
|
||||||
"uk": _(u"Ukrainian"),
|
|
||||||
"ur": _(u"Urdu"),
|
|
||||||
"uz": _(u"Uzbek"),
|
|
||||||
"ug": _(u"Uighur"),
|
|
||||||
"vi": _(u"Vietnamese"),
|
|
||||||
"cy": _(u"Welsh"),
|
|
||||||
"yi": _(u"Yiddish")
|
|
||||||
}
|
|
||||||
|
|
||||||
def available_languages():
|
def get_languages(self):
|
||||||
|
languages = {}
|
||||||
|
if config.app["translator"].get("engine") == "LibreTranslate":
|
||||||
|
translator = libre_translate.CustomLibreTranslateAPI(config.app["translator"]["lt_api_url"], config.app["translator"]["lt_api_key"])
|
||||||
|
languages = {l.get("code"): l.get("name") for l in translator.languages()}
|
||||||
|
elif config.app["translator"]["engine"] == "DeepL" and config.app["translator"]["deepl_api_key"] != "":
|
||||||
|
languages = {language.code: language.name for language in deep_l.languages()}
|
||||||
return dict(sorted(languages.items(), key=lambda x: x[1]))
|
return dict(sorted(languages.items(), key=lambda x: x[1]))
|
||||||
|
|||||||
@@ -16,23 +16,26 @@
|
|||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
############################################################
|
############################################################
|
||||||
from . import translator
|
|
||||||
import wx
|
import wx
|
||||||
|
from pubsub import pub
|
||||||
from wxUI.dialogs import baseDialog
|
from wxUI.dialogs import baseDialog
|
||||||
|
|
||||||
class translateDialog(baseDialog.BaseWXDialog):
|
class translateDialog(baseDialog.BaseWXDialog):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
languages = []
|
|
||||||
language_dict = translator.available_languages()
|
|
||||||
for k in language_dict:
|
|
||||||
languages.append(language_dict[k])
|
|
||||||
super(translateDialog, self).__init__(None, -1, title=_(u"Translate message"))
|
super(translateDialog, self).__init__(None, -1, title=_(u"Translate message"))
|
||||||
|
self.engines = ["LibreTranslate", "DeepL"]
|
||||||
panel = wx.Panel(self)
|
panel = wx.Panel(self)
|
||||||
sizer = wx.BoxSizer(wx.VERTICAL)
|
sizer = wx.BoxSizer(wx.VERTICAL)
|
||||||
|
staticEngine = wx.StaticText(panel, -1, _(u"Translation engine"))
|
||||||
|
self.engine_select = wx.ComboBox(panel, -1, choices=self.engines, style=wx.CB_READONLY)
|
||||||
|
self.engine_select.Bind(wx.EVT_COMBOBOX, lambda event: pub.sendMessage("translator.engine_changed", engine=self.engine_select.GetValue()))
|
||||||
staticDest = wx.StaticText(panel, -1, _(u"Target language"))
|
staticDest = wx.StaticText(panel, -1, _(u"Target language"))
|
||||||
self.dest_lang = wx.ComboBox(panel, -1, choices=languages, style = wx.CB_READONLY)
|
self.dest_lang = wx.ComboBox(panel, -1, style = wx.CB_READONLY)
|
||||||
self.dest_lang.SetFocus()
|
self.dest_lang.SetFocus()
|
||||||
self.dest_lang.SetSelection(0)
|
self.dest_lang.SetSelection(0)
|
||||||
|
engineSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
|
engineSizer.Add(staticEngine)
|
||||||
|
engineSizer.Add(self.engine_select)
|
||||||
listSizer = wx.BoxSizer(wx.HORIZONTAL)
|
listSizer = wx.BoxSizer(wx.HORIZONTAL)
|
||||||
listSizer.Add(staticDest)
|
listSizer.Add(staticDest)
|
||||||
listSizer.Add(self.dest_lang)
|
listSizer.Add(self.dest_lang)
|
||||||
@@ -40,6 +43,14 @@ class translateDialog(baseDialog.BaseWXDialog):
|
|||||||
ok.SetDefault()
|
ok.SetDefault()
|
||||||
cancel = wx.Button(panel, wx.ID_CANCEL)
|
cancel = wx.Button(panel, wx.ID_CANCEL)
|
||||||
self.SetEscapeId(wx.ID_CANCEL)
|
self.SetEscapeId(wx.ID_CANCEL)
|
||||||
|
sizer.Add(engineSizer, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
sizer.Add(listSizer, 0, wx.EXPAND | wx.ALL, 5)
|
||||||
|
sizer.Add(ok, 0, wx.ALIGN_CENTER | wx.ALL, 5)
|
||||||
|
sizer.Add(cancel, 0, wx.ALIGN_CENTER | wx.ALL, 5)
|
||||||
|
panel.SetSizer(sizer)
|
||||||
|
|
||||||
|
def set_languages(self, languages):
|
||||||
|
wx.CallAfter(self.dest_lang.SetItems, languages)
|
||||||
|
|
||||||
def get(self, control):
|
def get(self, control):
|
||||||
return getattr(self, control).GetSelection()
|
return getattr(self, control).GetSelection()
|
||||||
|
|||||||
@@ -37,3 +37,12 @@ ocr_image = string(default="win+alt+o")
|
|||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
vote=string(default="alt+win+shift+v")
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -56,3 +56,12 @@ ocr_image = string(default="win+alt+o")
|
|||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
vote=string(default="alt+win+shift+v")
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -56,5 +56,15 @@ update_buffer = string(default="control+alt+shift+u")
|
|||||||
ocr_image = string(default="win+alt+o")
|
ocr_image = string(default="win+alt+o")
|
||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
|
mute_conversation=string(default="control+alt+win+back")
|
||||||
find = string(default="control+win+{")
|
find = string(default="control+win+{")
|
||||||
vote=string(default="alt+win+shift+v")
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -56,5 +56,15 @@ update_buffer = string(default="control+alt+shift+u")
|
|||||||
ocr_image = string(default="win+alt+o")
|
ocr_image = string(default="win+alt+o")
|
||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
|
mute_conversation=string(default="control+alt+win+back")
|
||||||
find = string(default="control+win+{")
|
find = string(default="control+win+{")
|
||||||
vote=string(default="alt+win+shift+v")
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -57,3 +57,14 @@ accountConfiguration = string(default="control+win+shift+o")
|
|||||||
update_buffer = string(default="control+win+shift+u")
|
update_buffer = string(default="control+win+shift+u")
|
||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
|
mute_conversation=string(default="alt+win+shift+delete")
|
||||||
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -58,4 +58,14 @@ update_buffer = string(default="control+win+shift+u")
|
|||||||
ocr_image = string(default="win+alt+o")
|
ocr_image = string(default="win+alt+o")
|
||||||
open_in_browser = string(default="alt+control+win+return")
|
open_in_browser = string(default="alt+control+win+return")
|
||||||
add_alias=string(default="")
|
add_alias=string(default="")
|
||||||
|
mute_conversation=string(default="alt+win+shift+delete")
|
||||||
vote=string(default="alt+win+shift+v")
|
vote=string(default="alt+win+shift+v")
|
||||||
|
edit_post=string(default="")
|
||||||
|
open_favs_timeline=string(default="")
|
||||||
|
community_timeline=string(default="")
|
||||||
|
seekLeft=string(default="")
|
||||||
|
seekRight=string(default="")
|
||||||
|
manage_aliases=string(default="")
|
||||||
|
create_filter=string(default="")
|
||||||
|
manage_filters=string(default="")
|
||||||
|
manage_accounts=string(default="")
|
||||||
@@ -1 +1,2 @@
|
|||||||
from . import mastodon
|
from . import mastodon
|
||||||
|
from . import blueski
|
||||||
52
src/keystrokeEditor/actions/blueski.py
Normal file
52
src/keystrokeEditor/actions/blueski.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
actions = {
|
||||||
|
"up": _(u"Go up in the current buffer"),
|
||||||
|
"down": _(u"Go down in the current buffer"),
|
||||||
|
"left": _(u"Go to the previous buffer"),
|
||||||
|
"right": _(u"Go to the next buffer"),
|
||||||
|
"next_account": _(u"Focus the next session"),
|
||||||
|
"previous_account": _(u"Focus the previous session"),
|
||||||
|
"show_hide": _(u"Show or hide the GUI"),
|
||||||
|
"post_tweet": _("Make a new post"),
|
||||||
|
"post_reply": _(u"Reply"),
|
||||||
|
"post_retweet": _(u"Repost"),
|
||||||
|
"send_dm": _(u"Send direct message"),
|
||||||
|
"add_to_favourites": _("Add post to likes"),
|
||||||
|
"remove_from_favourites": _(u"Remove post from likes"),
|
||||||
|
"toggle_like": _("Add/remove post from likes"),
|
||||||
|
"follow": _(u"Open the user actions dialogue"),
|
||||||
|
"user_details": _(u"See user details"),
|
||||||
|
"view_item": _(u"Show post"),
|
||||||
|
"exit": _(u"Quit"),
|
||||||
|
"open_timeline": _(u"Open user timeline"),
|
||||||
|
"remove_buffer": _(u"Destroy buffer"),
|
||||||
|
"url": _(u"Open URL"),
|
||||||
|
"open_in_browser": _(u"View in browser"),
|
||||||
|
"volume_up": _(u"Increase volume by 5%"),
|
||||||
|
"volume_down": _(u"Decrease volume by 5%"),
|
||||||
|
"go_home": _(u"Jump to the first element of a buffer"),
|
||||||
|
"go_end": _(u"Jump to the last element of the current buffer"),
|
||||||
|
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
|
||||||
|
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
|
||||||
|
"delete": _("Delete post"),
|
||||||
|
"clear_buffer": _(u"Empty the current buffer"),
|
||||||
|
"repeat_item": _(u"Repeat last item"),
|
||||||
|
"copy_to_clipboard": _(u"Copy to clipboard"),
|
||||||
|
"toggle_buffer_mute": _(u"Mute/unmute the active buffer"),
|
||||||
|
"toggle_session_mute": _(u"Mute/unmute the current session"),
|
||||||
|
"toggle_autoread": _(u"Toggle the automatic reading of incoming posts in the active buffer"),
|
||||||
|
"search": _(u"Search"),
|
||||||
|
"find": _(u"Find a string in the currently focused buffer"),
|
||||||
|
"edit_keystrokes": _(u"Show the keystroke editor"),
|
||||||
|
"get_more_items": _(u"Load previous items"),
|
||||||
|
"open_conversation": _(u"View conversation"),
|
||||||
|
"check_for_updates": _(u"Check and download updates"),
|
||||||
|
"configuration": _(u"Opens the global settings dialogue"),
|
||||||
|
"accountConfiguration": _(u"Opens the account settings dialogue"),
|
||||||
|
"audio": _(u"Try to play a media file"),
|
||||||
|
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
||||||
|
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
||||||
|
"seekLeft": _(u"Seek media backward"),
|
||||||
|
"seekRight": _(u"Seek media forward"),
|
||||||
|
"manage_accounts": _(u"Manage accounts"),
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ actions = {
|
|||||||
"go_end": _(u"Jump to the last element of the current buffer"),
|
"go_end": _(u"Jump to the last element of the current buffer"),
|
||||||
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
|
"go_page_up": _(u"Jump 20 elements up in the current buffer"),
|
||||||
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
|
"go_page_down": _(u"Jump 20 elements down in the current buffer"),
|
||||||
# "update_profile": _(u"Edit profile"),
|
"update_profile": _(u"Edit profile"),
|
||||||
"delete": _("Delete post"),
|
"delete": _("Delete post"),
|
||||||
"clear_buffer": _(u"Empty the current buffer"),
|
"clear_buffer": _(u"Empty the current buffer"),
|
||||||
"repeat_item": _(u"Repeat last item"),
|
"repeat_item": _(u"Repeat last item"),
|
||||||
@@ -54,4 +54,15 @@ actions = {
|
|||||||
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
"update_buffer": _(u"Updates the buffer and retrieves possible lost items there."),
|
||||||
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
"ocr_image": _(u"Extracts the text from a picture and displays the result in a dialog."),
|
||||||
"add_alias": _("Adds an alias to an user"),
|
"add_alias": _("Adds an alias to an user"),
|
||||||
|
"mute_conversation": _("Mute/Unmute conversation"),
|
||||||
|
"edit_post": _(u"Edit the selected post"),
|
||||||
|
"vote": _(u"Vote in the selected poll"),
|
||||||
|
"open_favs_timeline": _(u"Open favorites timeline"),
|
||||||
|
"community_timeline": _(u"Open local/federated timeline"),
|
||||||
|
"seekLeft": _(u"Seek media backward"),
|
||||||
|
"seekRight": _(u"Seek media forward"),
|
||||||
|
"manage_aliases": _(u"Manage user aliases"),
|
||||||
|
"create_filter": _(u"Create a new filter"),
|
||||||
|
"manage_filters": _(u"Manage filters"),
|
||||||
|
"manage_accounts": _(u"Manage accounts"),
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
BIN
src/locales/bg/LC_MESSAGES/twblue.mo
Normal file
BIN
src/locales/bg/LC_MESSAGES/twblue.mo
Normal file
Binary file not shown.
3222
src/locales/bg/LC_MESSAGES/twblue.po
Normal file
3222
src/locales/bg/LC_MESSAGES/twblue.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import httpcore
|
|
||||||
httpcore.SyncHTTPTransport = httpcore.AsyncHTTPProxy
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ access_token = string(default="")
|
|||||||
instance = string(default="")
|
instance = string(default="")
|
||||||
user_name = string(default="")
|
user_name = string(default="")
|
||||||
ignored_clients = list(default=list())
|
ignored_clients = list(default=list())
|
||||||
|
type = string(default="mastodon")
|
||||||
|
|
||||||
[general]
|
[general]
|
||||||
relative_times = boolean(default=True)
|
relative_times = boolean(default=True)
|
||||||
@@ -13,7 +14,7 @@ persist_size = integer(default=0)
|
|||||||
load_cache_in_memory=boolean(default=True)
|
load_cache_in_memory=boolean(default=True)
|
||||||
show_screen_names = boolean(default=False)
|
show_screen_names = boolean(default=False)
|
||||||
hide_emojis = boolean(default=False)
|
hide_emojis = boolean(default=False)
|
||||||
buffer_order = list(default=list('home', 'local', 'mentions', 'direct_messages', 'sent', 'favorites', 'bookmarks', 'followers', 'following', 'blocked', 'muted', 'notifications'))
|
buffer_order = list(default=list('home', 'local', 'mentions', 'direct_messages', 'sent', 'favorites', 'bookmarks', 'followers', 'following', 'blocked', 'muted', 'notifications', 'announcements'))
|
||||||
boost_mode = string(default="ask")
|
boost_mode = string(default="ask")
|
||||||
disable_streaming = boolean(default=False)
|
disable_streaming = boolean(default=False)
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ indicate_img = boolean(default=True)
|
|||||||
[other_buffers]
|
[other_buffers]
|
||||||
timelines = list(default=list())
|
timelines = list(default=list())
|
||||||
searches = list(default=list())
|
searches = list(default=list())
|
||||||
|
communities = list(default=list())
|
||||||
lists = list(default=list())
|
lists = list(default=list())
|
||||||
followers_timelines = list(default=list())
|
followers_timelines = list(default=list())
|
||||||
following_timelines = list(default=list())
|
following_timelines = list(default=list())
|
||||||
@@ -52,6 +54,7 @@ post = string(default="$display_name, $safe_text $image_descriptions $date. $vis
|
|||||||
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
person = string(default="$display_name (@$screen_name). $followers followers, $following following, $posts posts. Joined $created_at.")
|
||||||
conversation = string(default="Conversation with $users. Last message: $last_post")
|
conversation = string(default="Conversation with $users. Last message: $last_post")
|
||||||
notification = string(default="$display_name $text, $date")
|
notification = string(default="$display_name $text, $date")
|
||||||
|
announcement = string(default="$text. Published $published_at. $read")
|
||||||
|
|
||||||
[filters]
|
[filters]
|
||||||
|
|
||||||
|
|||||||
@@ -63,13 +63,17 @@ class list(object):
|
|||||||
|
|
||||||
def get_selected(self):
|
def get_selected(self):
|
||||||
if self.system == "Windows":
|
if self.system == "Windows":
|
||||||
return self.list.GetFocusedItem()
|
item = self.list.GetFocusedItem()
|
||||||
|
if item == -1:
|
||||||
|
item = self.list.GetFirstSelected()
|
||||||
|
return item
|
||||||
else:
|
else:
|
||||||
return self.list.GetSelection()
|
return self.list.GetSelection()
|
||||||
|
|
||||||
def select_item(self, pos):
|
def select_item(self, pos):
|
||||||
if self.system == "Windows":
|
if self.system == "Windows":
|
||||||
self.list.Focus(pos)
|
self.list.Focus(pos)
|
||||||
|
self.list.Select(pos)
|
||||||
else:
|
else:
|
||||||
self.list.SetSelection(pos)
|
self.list.SetSelection(pos)
|
||||||
|
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
from __future__ import unicode_literals
|
|
||||||
from future import standard_library
|
|
||||||
standard_library.install_aliases()
|
|
||||||
from builtins import str
|
|
||||||
import winreg
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from platform_utils import paths
|
|
||||||
|
|
||||||
RUN_REGKEY = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
|
|
||||||
|
|
||||||
def is_installed(app_subkey):
|
|
||||||
"""Checks if the currently running copy is installed or portable variant. Requires the name of the application subkey found under the uninstall section in Windows registry."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\%s" % app_subkey)
|
|
||||||
inst_dir = winreg.QueryValueEx(key,"InstallLocation")[0]
|
|
||||||
except WindowsError:
|
|
||||||
return False
|
|
||||||
winreg.CloseKey(key)
|
|
||||||
try:
|
|
||||||
return os.stat(inst_dir) == os.stat(paths.app_path())
|
|
||||||
except WindowsError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def getAutoStart(app_name):
|
|
||||||
"""Queries if the automatic startup should be set for the application or not, depending on it's current state."""
|
|
||||||
|
|
||||||
try:
|
|
||||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_REGKEY)
|
|
||||||
val = winreg.QueryValueEx(key, str(app_name))[0]
|
|
||||||
return os.stat(val) == os.stat(sys.argv[0])
|
|
||||||
except (WindowsError, OSError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def setAutoStart(app_name, enable=True):
|
|
||||||
"""Configures automatic startup for the application, if the enable argument is set to True. If set to False, deletes the application AutoStart value."""
|
|
||||||
if getAutoStart(app_name) == enable:
|
|
||||||
return
|
|
||||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_REGKEY, 0, winreg.KEY_WRITE)
|
|
||||||
if enable:
|
|
||||||
winreg.SetValueEx(key, str(app_name), None, winreg.REG_SZ, paths.get_executable())
|
|
||||||
else:
|
|
||||||
winreg.DeleteValue(key, str(app_name))
|
|
||||||
@@ -11,9 +11,13 @@ import paths
|
|||||||
import config_utils
|
import config_utils
|
||||||
import config
|
import config
|
||||||
import application
|
import application
|
||||||
|
import asyncio # For async event handling
|
||||||
|
import wx
|
||||||
from pubsub import pub
|
from pubsub import pub
|
||||||
from controller import settings
|
from controller import settings
|
||||||
from sessions.mastodon import session as MastodonSession
|
from sessions.mastodon import session as MastodonSession
|
||||||
|
from sessions.gotosocial import session as GotosocialSession
|
||||||
|
from sessions.blueski import session as BlueskiSession # Import Blueski session
|
||||||
from . import manager
|
from . import manager
|
||||||
from . import wxUI as view
|
from . import wxUI as view
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ class sessionManagerController(object):
|
|||||||
# Initialize the manager, responsible for storing session objects.
|
# Initialize the manager, responsible for storing session objects.
|
||||||
manager.setup()
|
manager.setup()
|
||||||
self.view = view.sessionManagerWindow()
|
self.view = view.sessionManagerWindow()
|
||||||
|
# Handle new account synchronously on the UI thread
|
||||||
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
|
pub.subscribe(self.manage_new_account, "sessionmanager.new_account")
|
||||||
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
|
pub.subscribe(self.remove_account, "sessionmanager.remove_account")
|
||||||
if self.started == False:
|
if self.started == False:
|
||||||
@@ -66,12 +71,44 @@ class sessionManagerController(object):
|
|||||||
continue
|
continue
|
||||||
if config_test.get("mastodon") != None:
|
if config_test.get("mastodon") != None:
|
||||||
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
|
name = _("{account_name}@{instance} (Mastodon)").format(account_name=config_test["mastodon"]["user_name"], instance=config_test["mastodon"]["instance"].replace("https://", ""))
|
||||||
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "":
|
if config_test["mastodon"]["instance"] != "" and config_test["mastodon"]["access_token"] != "": # Basic validation
|
||||||
sessionsList.append(name)
|
sessionsList.append(name)
|
||||||
self.sessions.append(dict(type="mastodon", id=i))
|
self.sessions.append(dict(type=config_test["mastodon"].get("type", "mastodon"), id=i))
|
||||||
else:
|
elif config_test.get("blueski") != None: # Check for Blueski config
|
||||||
|
handle = config_test["blueski"].get("handle")
|
||||||
|
did = config_test["blueski"].get("did") # DID confirms it was authorized
|
||||||
|
if handle and did:
|
||||||
|
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||||
|
sessionsList.append(name)
|
||||||
|
self.sessions.append(dict(type="blueski", id=i))
|
||||||
|
else: # Incomplete config, might be an old attempt or error
|
||||||
|
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||||
|
# Optionally delete malformed config here too
|
||||||
try:
|
try:
|
||||||
log.debug("Deleting session %s" % (i,))
|
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||||
|
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||||
|
continue
|
||||||
|
elif config_test.get("atprotosocial") != None: # Legacy config namespace
|
||||||
|
handle = config_test["atprotosocial"].get("handle")
|
||||||
|
did = config_test["atprotosocial"].get("did")
|
||||||
|
if handle and did:
|
||||||
|
name = _("{handle} (Bluesky)").format(handle=handle)
|
||||||
|
sessionsList.append(name)
|
||||||
|
self.sessions.append(dict(type="blueski", id=i))
|
||||||
|
else: # Incomplete config, might be an old attempt or error
|
||||||
|
log.warning(f"Incomplete Blueski session config found for {i}, skipping.")
|
||||||
|
# Optionally delete malformed config here too
|
||||||
|
try:
|
||||||
|
log.debug("Deleting incomplete Blueski session %s" % (i,))
|
||||||
|
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||||
|
except Exception as e:
|
||||||
|
log.exception(f"Error deleting incomplete Blueski session {i}: {e}")
|
||||||
|
continue
|
||||||
|
else: # Unknown or other session type not explicitly handled here for display
|
||||||
|
try:
|
||||||
|
log.debug("Deleting session %s with unknown type" % (i,))
|
||||||
shutil.rmtree(os.path.join(paths.config_path(), i))
|
shutil.rmtree(os.path.join(paths.config_path(), i))
|
||||||
except:
|
except:
|
||||||
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
|
output.speak("An exception was raised while attempting to clean malformed session data. See the error log for details. If this message persists, contact the developers.",True)
|
||||||
@@ -94,36 +131,114 @@ class sessionManagerController(object):
|
|||||||
# Create the session object based in session type.
|
# Create the session object based in session type.
|
||||||
if i.get("type") == "mastodon":
|
if i.get("type") == "mastodon":
|
||||||
s = MastodonSession.Session(i.get("id"))
|
s = MastodonSession.Session(i.get("id"))
|
||||||
s.get_configuration()
|
elif i.get("type") == "gotosocial":
|
||||||
if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
s = GotosocialSession.Session(i.get("id"))
|
||||||
try:
|
elif i.get("type") == "blueski": # Handle Blueski session type
|
||||||
s.login()
|
s = BlueskiSession.Session(i.get("id"))
|
||||||
except Exception as e:
|
else:
|
||||||
log.exception("Exception during login on a TWBlue session.")
|
log.warning(f"Unknown session type '{i.get('type')}' for ID {i.get('id')}. Skipping.")
|
||||||
continue
|
continue
|
||||||
sessions.sessions[i.get("id")] = s
|
|
||||||
self.new_sessions[i.get("id")] = s
|
s.get_configuration() # Load per-session configuration
|
||||||
|
# For Blueski, this loads from its specific config file.
|
||||||
|
|
||||||
|
# Login is now primarily handled by session.start() via mainController,
|
||||||
|
# which calls _ensure_dependencies_ready().
|
||||||
|
# Explicit s.login() here might be redundant or premature if full app context isn't ready.
|
||||||
|
# We'll rely on the mainController to call session.start() which handles login.
|
||||||
|
# if i.get("id") not in config.app["sessions"]["ignored_sessions"]:
|
||||||
|
# try:
|
||||||
|
# # For Blueski, login is async and handled by session.start()
|
||||||
|
# # if not s.is_ready(): # Only attempt login if not already ready
|
||||||
|
# # log.info(f"Session {s.uid} ({s.kind}) not ready, login will be attempted by start().")
|
||||||
|
# pass
|
||||||
|
# except Exception as e:
|
||||||
|
# log.exception(f"Exception during pre-emptive login check for session {s.uid} ({s.kind}).")
|
||||||
|
# continue
|
||||||
|
# Try to auto-login for Blueski so the app starts with buffers ready
|
||||||
|
try:
|
||||||
|
if i.get("type") == "blueski":
|
||||||
|
s.login()
|
||||||
|
except Exception:
|
||||||
|
log.exception("Auto-login failed for Blueski session %s", i.get("id"))
|
||||||
|
|
||||||
|
sessions.sessions[i.get("id")] = s # Add to global session store
|
||||||
|
self.new_sessions[i.get("id")] = s # Track as a new session for this manager instance
|
||||||
# self.view.destroy()
|
# self.view.destroy()
|
||||||
|
|
||||||
def show_auth_error(self):
|
def show_auth_error(self):
|
||||||
error = view.auth_error()
|
error = view.auth_error() # This seems to be a generic auth error display
|
||||||
|
|
||||||
def manage_new_account(self, type):
|
def manage_new_account(self, type):
|
||||||
# Generic settings for all account types.
|
# Generic settings for all account types.
|
||||||
location = (str(time.time())[-6:])
|
location = (str(time.time())[-6:]) # Unique ID for the session config directory
|
||||||
log.debug("Creating %s session in the %s path" % (type, location))
|
log.debug("Creating %s session in the %s path" % (type, location))
|
||||||
|
|
||||||
|
s: sessions.base.baseSession | None = None # Type hint for session object
|
||||||
|
|
||||||
if type == "mastodon":
|
if type == "mastodon":
|
||||||
s = MastodonSession.Session(location)
|
s = MastodonSession.Session(location)
|
||||||
|
elif type == "blueski":
|
||||||
|
s = BlueskiSession.Session(location)
|
||||||
|
# Add other session types here if needed (e.g., gotosocial)
|
||||||
|
# elif type == "gotosocial":
|
||||||
|
# s = GotosocialSession.Session(location)
|
||||||
|
|
||||||
|
if not s:
|
||||||
|
log.error(f"Unsupported session type for creation: {type}")
|
||||||
|
self.view.show_unauthorised_error() # Or a more generic "cannot create" error
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
result = s.authorise()
|
result = s.authorise()
|
||||||
if result == True:
|
if result == True:
|
||||||
self.sessions.append(dict(id=location, type=type))
|
# Session config (handle, did for atproto) should be saved by authorise/login.
|
||||||
self.view.add_new_session_to_list()
|
# Here we just update the session manager's internal list and UI.
|
||||||
|
session_type_for_dict = type # Store the actual type string
|
||||||
|
if hasattr(s, 'settings') and s.settings and s.settings.get(type) and s.settings[type].get("type"):
|
||||||
|
# Mastodon might have a more specific type in its settings (e.g. gotosocial)
|
||||||
|
session_type_for_dict = s.settings[type].get("type")
|
||||||
|
|
||||||
|
self.sessions.append(dict(id=location, type=session_type_for_dict))
|
||||||
|
self.view.add_new_session_to_list() # This should update the UI list
|
||||||
|
# The session object 's' itself isn't stored in self.new_sessions until do_ok if app is restarting
|
||||||
|
# But for immediate use if not restarting, it might need to be added to sessions.sessions
|
||||||
|
sessions.sessions[location] = s # Make it globally available immediately
|
||||||
|
self.new_sessions[location] = s
|
||||||
|
# Sync with global config
|
||||||
|
if location not in config.app["sessions"]["sessions"]:
|
||||||
|
config.app["sessions"]["sessions"].append(location)
|
||||||
|
config.app.write()
|
||||||
|
|
||||||
|
|
||||||
|
else: # Authorise returned False or None
|
||||||
|
self.view.show_unauthorised_error()
|
||||||
|
# Clean up the directory if authorization failed and nothing was saved
|
||||||
|
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||||
|
log.info(f"Cleaned up directory for failed auth: {location}")
|
||||||
|
except Exception as e_rm:
|
||||||
|
log.error(f"Error cleaning up directory {location} after failed auth: {e_rm}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Error during new account authorization for type {type}: {e}", exc_info=True)
|
||||||
|
self.view.show_unauthorised_error() # Show generic error
|
||||||
|
# Clean up
|
||||||
|
if os.path.exists(os.path.join(paths.config_path(), location)):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(os.path.join(paths.config_path(), location))
|
||||||
|
except Exception as e_rm:
|
||||||
|
log.error(f"Error cleaning up directory {location} after exception: {e_rm}")
|
||||||
|
|
||||||
|
|
||||||
def remove_account(self, index):
|
def remove_account(self, index):
|
||||||
selected_account = self.sessions[index]
|
selected_account = self.sessions[index]
|
||||||
self.view.remove_session(index)
|
self.view.remove_session(index)
|
||||||
self.removed_sessions.append(selected_account.get("id"))
|
self.removed_sessions.append(selected_account.get("id"))
|
||||||
self.sessions.remove(selected_account)
|
self.sessions.remove(selected_account)
|
||||||
|
if selected_account.get("id") in config.app["sessions"]["sessions"]:
|
||||||
|
config.app["sessions"]["sessions"].remove(selected_account.get("id"))
|
||||||
|
config.app.write()
|
||||||
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
|
shutil.rmtree(path=os.path.join(paths.config_path(), selected_account.get("id")), ignore_errors=True)
|
||||||
|
|
||||||
def configuration(self):
|
def configuration(self):
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user