Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d1199fb6 | ||
|
|
c063d056b2 | ||
|
|
d7bfd2f417 | ||
|
|
d2b4cb0440 | ||
|
|
72c4f4e54d | ||
|
|
dd29eaca42 | ||
|
|
8bdb8ed5a5 | ||
|
|
dca34b8b5d | ||
|
|
c30c5c07c7 | ||
|
|
361e23b9ec | ||
|
|
8a26a4ccf9 | ||
|
|
c1338985d4 | ||
|
|
440e29975a | ||
|
|
b7f214daf5 | ||
|
|
ec22b709b5 | ||
|
|
0570f2c5bd | ||
|
|
dd77ba903b | ||
|
|
06df51ca08 | ||
|
|
1c17165c86 | ||
|
|
560269c299 | ||
|
|
7b8216ea70 | ||
|
|
bd61be8a86 | ||
|
|
cdd46c0b62 | ||
|
|
96acc6cb39 | ||
|
|
975dc77111 | ||
|
|
6bcfc08943 | ||
|
|
3d48abdf73 | ||
|
|
9abae5030b | ||
|
|
e3eed8bdd4 | ||
|
|
aca5f8609b | ||
|
|
1cc6f5068a | ||
|
|
95c0677b55 | ||
|
|
4cd2b33c53 | ||
|
|
e7cb4edf3a | ||
|
|
1d383b4e93 | ||
|
|
ed2920275d | ||
|
|
0ba5c97d78 | ||
|
|
105ff76959 | ||
|
|
eb0a6eba7e | ||
|
|
0ace395cfb | ||
|
|
ef31eb2e88 | ||
|
|
2427a3782c | ||
|
|
05ee23aefa | ||
|
|
2d589cbe6b | ||
|
|
304283c8d8 | ||
|
|
cbb01085c4 | ||
|
|
a8d3504424 | ||
|
|
fb92f4f703 | ||
|
|
4961c0ae6e | ||
|
|
6a65998e42 | ||
|
|
0c38adba78 | ||
|
|
370bf9d5b0 | ||
|
|
1127680425 | ||
|
|
ed029c59ef | ||
|
|
74351af905 | ||
|
|
ca7af8baf9 | ||
|
|
4dadb1bd5a | ||
|
|
1952f0ebcd | ||
|
|
41728017cb | ||
|
|
7cf8e4f4e0 | ||
|
|
0331cb099c | ||
|
|
f4a6ff36b4 | ||
|
|
5748f1290d | ||
|
|
0de8c0b5ac | ||
|
|
f5a512b8c9 | ||
|
|
1a4a96461a | ||
|
|
b4675b70e1 | ||
|
|
74f4e753ef | ||
|
|
7fff1a7b3a | ||
|
|
0c5b329179 | ||
|
|
50767f701f | ||
|
|
425b06aa71 | ||
|
|
804618ad63 | ||
|
|
df22f26628 | ||
|
|
c53e6552eb | ||
|
|
66dad284c7 | ||
|
|
3fab8b08b9 | ||
|
|
f570483b62 | ||
|
|
3df61e9e59 | ||
|
|
4bf4e75cac | ||
|
|
3d6ae089a1 | ||
|
|
cd6b7675d4 | ||
|
|
8a593f50c3 | ||
|
|
ab3843dbdb | ||
|
|
d1f3aea26e | ||
|
|
8431f83ede | ||
|
|
c455644cca | ||
|
|
823a47e182 | ||
|
|
92892e3e7b | ||
|
|
8013c1675f | ||
|
|
ff69e4548c | ||
|
|
29a1f1c2fd | ||
|
|
97e8d7144f | ||
|
|
18d21a03bb | ||
|
|
8f4b0de636 | ||
|
|
ab96ae1fee | ||
|
|
fb1fda689c | ||
|
|
776131b1de | ||
|
|
0824502ddd | ||
|
|
9adadaa57d | ||
|
|
c3e8f91364 | ||
|
|
e78437cb2a | ||
|
|
70cc977e16 | ||
|
|
d5fc03db0e | ||
|
|
f9d4d48148 | ||
|
|
37d08e8989 | ||
|
|
76156a1c75 | ||
|
|
4e1f1cbf0f | ||
|
|
3280b92a09 | ||
|
|
e3217a5de9 | ||
|
|
46b3288880 | ||
|
|
1826d2906f | ||
|
|
9271a0a9c1 | ||
|
|
3065e232be | ||
|
|
d1c81d432e | ||
|
|
db0f8e4543 | ||
|
|
5c3f3b7653 | ||
|
|
72b7b79caf | ||
|
|
8ec74dffcc | ||
|
|
c9f4615c40 | ||
|
|
d3a0eb0dbc | ||
|
|
1bd8d7f804 | ||
|
|
93d4077d1e | ||
|
|
a7da4dc079 | ||
|
|
569f266390 | ||
|
|
986432addd | ||
|
|
d5440b962b | ||
|
|
03b8098b3c | ||
|
|
aa3e464b35 | ||
|
|
59d1033a92 | ||
|
|
6fcd779530 | ||
|
|
bccbdff9b2 | ||
|
|
80bf0aa41f | ||
|
|
0b48346d02 | ||
|
|
2272b42c6f | ||
|
|
7f5756671d | ||
|
|
f549248c08 | ||
|
|
aa1c46ff22 | ||
|
|
63c1b84b41 | ||
|
|
8cdd84152d | ||
|
|
8bcd9f3a5b | ||
|
|
4e8f45d7d8 | ||
|
|
97a6b88d59 | ||
|
|
7981083cad | ||
|
|
48baeceaa8 | ||
|
|
4ce666042e | ||
|
|
f0091fb146 | ||
|
|
a8b81a51e3 | ||
|
|
6ab2867f27 | ||
|
|
0d32569633 | ||
|
|
31f2b4afea | ||
|
|
c2e07a89b8 | ||
|
|
ac8f0cf315 | ||
|
|
e4b0868036 | ||
|
|
3790472b4c | ||
|
|
0d2637861d | ||
|
|
4773ee5f64 | ||
|
|
30e6d5e300 | ||
|
|
53e28e3bc6 | ||
|
|
8cb96e9254 | ||
|
|
b200dc2525 | ||
|
|
5c688007c8 | ||
|
|
64021fe428 | ||
|
|
b6adc032a2 | ||
|
|
644b663ed1 | ||
|
|
56cd91d40e | ||
|
|
9eb9df649d | ||
|
|
dbf47847fe | ||
|
|
5ddace97f4 | ||
|
|
605cd12a6e | ||
|
|
a71aee9487 | ||
|
|
51e0fc420f | ||
|
|
552c891cc3 | ||
|
|
cd092108c2 | ||
|
|
4715ea4497 | ||
|
|
ed9a92896c | ||
|
|
a81a7b162d | ||
|
|
fa2288c497 | ||
|
|
ddf23d40b2 | ||
|
|
8087fd7e97 | ||
|
|
e6e51f625e | ||
|
|
e53af93409 | ||
|
|
1228d6a534 | ||
|
|
1d47c586d3 | ||
|
|
a964d51ba5 | ||
|
|
cdca509183 | ||
|
|
a6014b84ee | ||
|
|
0748f30cc1 | ||
|
|
ded440078a | ||
|
|
46a98782fd | ||
|
|
eaafabe94f | ||
|
|
fa2c6c2f07 | ||
|
|
db75aaa25b | ||
|
|
693265658f | ||
|
|
4fc65b0f40 | ||
|
|
623f7dc40f | ||
|
|
e61eb40250 | ||
|
|
01325ad009 | ||
|
|
150db2fc02 | ||
|
|
2ee8bac6a0 | ||
|
|
ca71667975 | ||
|
|
68044fa1bb | ||
|
|
6882103461 | ||
|
|
cccd21aff1 | ||
|
|
5b836d50b2 | ||
|
|
4a140e6c79 | ||
|
|
8575c83980 | ||
|
|
052cedb39b | ||
|
|
22468edf6b | ||
|
|
164a41270c | ||
|
|
1e5e64ae2c | ||
|
|
b5531b0b7f | ||
|
|
117bf43c25 | ||
|
|
fc757cc043 | ||
|
|
89fdd31cd4 | ||
|
|
4dbe76f2bd | ||
|
|
bab1f13e04 | ||
|
|
9d2db5a648 | ||
|
|
35f659249e | ||
|
|
eb4c1cd8da | ||
|
|
ee2cf6f965 | ||
|
|
38b43936ce | ||
|
|
47148aa75d | ||
|
|
8b51fd3eb8 | ||
|
|
7db687602b | ||
|
|
d573b38d10 | ||
|
|
f688578714 | ||
|
|
d759f67b11 | ||
|
|
5e173c55e8 | ||
|
|
290fc80f3e | ||
|
|
e66550867c | ||
|
|
bd1d6ff0b0 | ||
|
|
dfb7ab53a5 | ||
|
|
f2218a822e | ||
|
|
bede45fd7f | ||
|
|
f42b8536e5 | ||
|
|
d42591284f | ||
|
|
4a30cfa6b3 | ||
|
|
93b856aec8 | ||
|
|
505a6d7d56 | ||
|
|
abef778c7f | ||
|
|
d01547d3de | ||
|
|
7728afe79f | ||
|
|
542aca3dc2 | ||
|
|
d21d046443 | ||
|
|
7340b5e135 | ||
|
|
4337c45674 | ||
|
|
3a78c40389 | ||
|
|
211d93a0e4 | ||
|
|
997f8f71d2 | ||
|
|
0dcef54fed | ||
|
|
d3d7e11b08 | ||
|
|
1d51e53a46 | ||
|
|
8e703d86eb | ||
|
|
02a565b8e5 | ||
|
|
5bc3fee20b | ||
|
|
c709abbfa4 | ||
|
|
08bd8915c5 | ||
|
|
35d3e1cf6b | ||
|
|
c9b8125013 | ||
|
|
02b98cceae | ||
|
|
5a07bb4e3b | ||
|
|
c4189646f4 | ||
|
|
0ba3577c96 | ||
|
|
f7bc98811e | ||
|
|
8225848493 | ||
|
|
3bdb0bf40f | ||
|
|
e4c6e1b246 | ||
|
|
2c77a8e229 | ||
|
|
251c9de87c | ||
|
|
ba8f48ee83 | ||
|
|
ca5e74ee98 | ||
|
|
111d9040a7 | ||
|
|
85ac8ce43b | ||
|
|
14e1e6b549 | ||
|
|
70a9ab8996 | ||
|
|
d24b297685 | ||
|
|
7bf3fa71d6 | ||
|
|
ac9d61eaad | ||
|
|
ece2aa0e87 | ||
|
|
ac779ef31b | ||
|
|
e2c32de5c2 | ||
|
|
6bd9afe017 | ||
|
|
91799adb9a | ||
|
|
3bfabc90f3 | ||
|
|
6b21c1deaa | ||
|
|
d674deeca6 | ||
|
|
573f99993a | ||
|
|
e6dfde28f6 | ||
|
|
389ef305ab | ||
|
|
2aa1b731ee | ||
|
|
656aebdb5d | ||
|
|
e9d6c2e5bf | ||
|
|
e097f134b7 | ||
|
|
d296bebe1c | ||
|
|
234fb48b93 | ||
|
|
a54ae70746 | ||
|
|
5b7565c8f8 | ||
|
|
dea781b423 | ||
|
|
930f4f0656 | ||
|
|
16489432cf | ||
|
|
84534dfc63 | ||
|
|
8c3d273c34 | ||
|
|
b2bae67ed2 | ||
|
|
825a6661f3 | ||
|
|
c4fa4c6847 | ||
|
|
c17a314640 | ||
|
|
7fa83d0d3e | ||
|
|
fb11d0f477 | ||
|
|
b11d1f6d39 | ||
|
|
896505e49d | ||
|
|
ad3c6f5d36 | ||
|
|
b4ba9ab7d8 | ||
|
|
4cd3e84178 | ||
|
|
15b41a9bd8 | ||
|
|
89c1cc7add | ||
|
|
0a1e2d3a3d | ||
|
|
3a2a9f90d0 | ||
|
|
ca492e2e93 | ||
|
|
ec3974f2f0 | ||
|
|
478fb421bf | ||
|
|
0f8d94ac2f | ||
|
|
1a884d2546 | ||
|
|
16e688d4c1 | ||
|
|
369612afa8 | ||
|
|
16bfaddf2c | ||
|
|
1085d1caf6 | ||
|
|
abf5b19758 | ||
|
|
44e2fc3a23 | ||
|
|
e505b0a607 | ||
|
|
8ca7f5cd3e | ||
|
|
a74ad9dd63 | ||
|
|
c70e5ebf7e | ||
|
|
679276c2e2 | ||
|
|
5faacc1f53 | ||
|
|
4818ee7a25 | ||
|
|
0fb5132d24 | ||
|
|
ed6be52228 | ||
|
|
1dc5ea1655 | ||
|
|
6e04638441 | ||
|
|
89e9b85551 | ||
|
|
5bb829a6bc | ||
|
|
92611680e4 | ||
|
|
78f31b7cf1 | ||
|
|
79a1bc98b2 | ||
|
|
0b05d480c7 | ||
|
|
7d1011400e | ||
|
|
3a4794bacb | ||
|
|
56790262f3 | ||
|
|
fa436b61be | ||
|
|
bb1a3f4cea | ||
|
|
36cafbe87b | ||
|
|
a84a5a677a | ||
|
|
6bec95e084 | ||
|
|
290571f305 | ||
|
|
7ce9470bf3 | ||
|
|
9eba65b711 | ||
|
|
0400c804aa | ||
|
|
7ea1a24857 | ||
|
|
80d3f5336b | ||
|
|
6a208cf040 | ||
|
|
9cef61d447 | ||
|
|
2cb9f69a9d | ||
|
|
276f2bed34 | ||
|
|
2f05cd0651 | ||
|
|
1ba3671873 | ||
|
|
6d02cc903c | ||
|
|
ff60b3902e | ||
|
|
e641dc0d32 | ||
|
|
8535da995f | ||
|
|
baad48f044 | ||
|
|
72009a693b | ||
|
|
b3adf15f12 | ||
|
|
e8b48dfd84 | ||
|
|
7c6fb38b02 | ||
|
|
28f332f56a | ||
|
|
6fdc208ca7 | ||
|
|
2f74357aac | ||
|
|
e692f80bcf | ||
|
|
a411b80fc2 | ||
|
|
837013e97c | ||
|
|
360b37ae13 | ||
|
|
c69229934e | ||
|
|
8288ad4733 | ||
|
|
7d627edfce | ||
|
|
04279395bc | ||
|
|
14b38af8da | ||
|
|
3ae00646c7 | ||
|
|
43fde20643 | ||
|
|
6527c067c6 | ||
|
|
2f374e52ca | ||
|
|
b1acfecd4f | ||
|
|
5448f10b1a | ||
|
|
3b78971a39 | ||
|
|
11436a80ee | ||
|
|
3567f323c7 | ||
|
|
6f05fd102f | ||
|
|
4897d63029 | ||
|
|
5e92189b97 | ||
|
|
30bf77d4ba | ||
|
|
b2772924bd | ||
|
|
6c1c8cf336 | ||
|
|
f91d8a979d | ||
|
|
17b91c4bb8 | ||
|
|
a3a4fe7505 | ||
|
|
02e8bd2f74 | ||
|
|
bc8f20977e | ||
|
|
12a97cacc8 | ||
|
|
4052bb72b6 | ||
|
|
458b519845 | ||
|
|
07a22564c2 | ||
|
|
31a95676b7 | ||
|
|
ba81261ee8 | ||
|
|
944cf32bb9 | ||
|
|
ed34a46865 | ||
|
|
a4befc4cd3 | ||
|
|
7b15cf1f19 | ||
|
|
df03509029 | ||
|
|
354ce3b8a3 | ||
|
|
66192a8d53 | ||
|
|
7f2a89d86c | ||
|
|
9eaa172030 | ||
|
|
f932b973f8 | ||
|
|
40916e2fc3 | ||
|
|
5faedc35f3 | ||
|
|
de32da49f3 | ||
|
|
f5714b3b69 | ||
|
|
1155888ced | ||
|
|
d128cec90d | ||
|
|
3a8ee3e2dd | ||
|
|
1164d3a5bd | ||
|
|
fb2a39280a | ||
|
|
a1faba6b0c | ||
|
|
ca24b82f54 | ||
|
|
395edb3e4c | ||
|
|
691879b2b2 | ||
|
|
8187f09d26 | ||
|
|
e82951722d | ||
|
|
1042b9c39a | ||
|
|
3177b667b8 | ||
|
|
5fb85ce822 | ||
|
|
500d72702a | ||
|
|
31b13401a2 | ||
|
|
338e0f6023 | ||
|
|
df4b9c312a | ||
|
|
07d32dbfd8 | ||
|
|
0d887b73a0 | ||
|
|
685a1cd2a4 | ||
|
|
8f9f41efe5 | ||
|
|
dde4e565c8 | ||
|
|
94575b5a07 | ||
|
|
3bf822170c | ||
|
|
deca04ed3e | ||
|
|
97908ec84b | ||
|
|
20dcb48ec5 | ||
|
|
7bad07f717 | ||
|
|
2f85f01a1a | ||
|
|
a86d07f854 | ||
|
|
414129d968 | ||
|
|
5d664b86cb | ||
|
|
d6c904d38a | ||
|
|
ab04a872e2 | ||
|
|
7f0a16d701 | ||
|
|
43e89e2119 | ||
|
|
baf211ff82 | ||
|
|
c5c55fd9fd | ||
|
|
8403e3e9b4 | ||
|
|
fd08d19939 | ||
|
|
b14717bb48 | ||
|
|
f969c04c47 | ||
|
|
2fe35f9ded | ||
|
|
852a1a7c4e | ||
|
|
f7120d824d | ||
|
|
c15092e850 | ||
|
|
7ca827559b | ||
|
|
f7beabacf9 | ||
|
|
848bb19f76 | ||
|
|
981ef684a7 | ||
|
|
55fc4356da | ||
|
|
c17118d157 | ||
|
|
95e9891825 | ||
|
|
0984c73c01 | ||
|
|
0425bd4348 | ||
|
|
f9e7a71920 | ||
|
|
ba72fe7602 | ||
|
|
6da591beb1 | ||
|
|
16c9e2bb23 | ||
|
|
3689b5d527 | ||
|
|
c277c16a53 | ||
|
|
1bc3e15da3 | ||
|
|
3ac895ec66 | ||
|
|
08bdc7bc08 | ||
|
|
03a137da4a | ||
|
|
8df6413705 | ||
|
|
5d56c17128 | ||
|
|
87c74f9396 | ||
|
|
2b7c80ef2d | ||
|
|
823215aea6 | ||
|
|
5ad734cdea | ||
|
|
48405bcb94 | ||
|
|
0f9b88e64e | ||
|
|
10a827504b | ||
|
|
1d24e9135b | ||
|
|
a5c190f6db | ||
|
|
f61f68d2ac | ||
|
|
2c5705c3ae | ||
|
|
c4547bbeb4 | ||
|
|
e7296d5d14 | ||
|
|
364cd2c488 | ||
|
|
855f871041 | ||
|
|
8dd32a341b | ||
|
|
ebe47b345d | ||
|
|
454dc204c8 | ||
|
|
d0c88fbd0d | ||
|
|
e68ebc8336 | ||
|
|
cca97d410e | ||
|
|
eafd87fb17 | ||
|
|
71ee3fbec4 | ||
|
|
11fe81fdf1 | ||
|
|
9640c604b9 | ||
|
|
6273b3287f | ||
|
|
9c5e3a3eca | ||
|
|
9098c1f6a6 | ||
|
|
a2439be540 | ||
|
|
a2fb3fe1a0 | ||
|
|
560e3369e4 | ||
|
|
491175cecc | ||
|
|
6d96d327f1 | ||
|
|
6ee2851943 | ||
|
|
192a3d4ec2 | ||
|
|
c6f7911c82 | ||
|
|
9a1f0c734a | ||
|
|
52f879490e | ||
|
|
8df3c34cf4 | ||
|
|
2fb6a9bb0a | ||
|
|
5ae58fb2a6 | ||
|
|
43798ea1d9 | ||
|
|
323042bd96 | ||
|
|
172b9eb26d | ||
|
|
1b90c613f7 | ||
|
|
c6b4460389 | ||
|
|
497318b722 | ||
|
|
423f53880d | ||
|
|
a93ede25e9 | ||
|
|
b4d3e0274f | ||
|
|
4f116ab3e6 | ||
|
|
75b2880b63 | ||
|
|
1149980a47 | ||
|
|
87b48cd67d | ||
|
|
4be09a034e | ||
|
|
3c0b57333c | ||
|
|
0c9b23eee9 | ||
|
|
d11f94095f | ||
|
|
133ed3cc53 | ||
|
|
7bc50fac36 | ||
|
|
fbdd017d38 | ||
|
|
8fdb4a8a4c | ||
|
|
e1b1ae503f | ||
|
|
7b44913724 | ||
|
|
7e3b2fc254 | ||
|
|
fc6a8fbafc | ||
|
|
111b9d8641 | ||
|
|
eeea27ca82 | ||
|
|
fc2ff79329 | ||
|
|
f755314c3b | ||
|
|
caf02056ab | ||
|
|
4b26d5ed40 | ||
|
|
885d9932e2 | ||
|
|
f6c355498b | ||
|
|
455f0f5c67 | ||
|
|
e99370683b | ||
|
|
9fb1551bef | ||
|
|
8d40301ddf | ||
|
|
93fb2b8bca | ||
|
|
97178a603f | ||
|
|
f13c1d21c0 | ||
|
|
7e28c8693c | ||
|
|
c95181b0cf | ||
|
|
b2c5051d31 | ||
|
|
6e46a8dfda | ||
|
|
99c9a698a0 | ||
|
|
aa5e9ef5c7 | ||
|
|
4f6d617829 | ||
|
|
05c5ffa43c | ||
|
|
9d7bfec6d6 | ||
|
|
d8f7a41bdd | ||
|
|
6c36324ed4 | ||
|
|
e2f3201ddb | ||
|
|
34f17f45b1 | ||
|
|
1cf4722830 | ||
|
|
7c4327ac71 | ||
|
|
48caa605c7 | ||
|
|
edb1383d60 | ||
|
|
2a653f2299 | ||
|
|
eba83fdec8 | ||
|
|
5f173dd395 | ||
|
|
2115f6d638 | ||
|
|
5228d44ade | ||
|
|
ce19ebbbdd | ||
|
|
5e60a5e04d | ||
|
|
9f523b54a4 | ||
|
|
6c13943f1a | ||
|
|
b0bf93a4d9 | ||
|
|
b766e827c9 | ||
|
|
424a8b64bf | ||
|
|
9108efc8c9 | ||
|
|
f94c3612be | ||
|
|
95ddfd21eb | ||
|
|
ae5618c072 | ||
|
|
d083a286df | ||
|
|
e0db170aec | ||
|
|
e9d0f0f1f3 | ||
|
|
08d4c08cbc | ||
|
|
15d24c6607 | ||
|
|
c085ef8ce1 | ||
|
|
2d76befea1 | ||
|
|
20c54fb7e3 | ||
|
|
c5834fad4d | ||
|
|
640704ee2a | ||
|
|
18dd37b402 | ||
|
|
54d6719307 | ||
|
|
255e7fda98 | ||
|
|
1a6e8c8e74 | ||
|
|
b642d05a10 | ||
|
|
920619a988 | ||
|
|
acc24b552d | ||
|
|
ba94788fbe | ||
|
|
2758bb9293 | ||
|
|
4635456c1a | ||
|
|
89f9821255 | ||
|
|
8690ae6a1b | ||
|
|
483d7ef58d | ||
|
|
e1d7ebcc22 | ||
|
|
45fbff1b4a | ||
|
|
f99f5a223f | ||
|
|
c0e4cd4965 | ||
|
|
7d796e3dae | ||
|
|
1d3a9040cb | ||
|
|
29cce0a4cc | ||
|
|
c59c3eeb0b | ||
|
|
cab80e4d62 | ||
|
|
f7c5322e36 | ||
|
|
cb64e1f247 | ||
|
|
afee6b5dd9 | ||
|
|
9f01eab324 | ||
|
|
80d48077c9 | ||
|
|
1bc6f87844 | ||
|
|
0ea112b145 | ||
|
|
768edd744d | ||
|
|
1aadf4e7bd | ||
|
|
2d92e475b5 | ||
|
|
a28c3eb5cd | ||
|
|
97208e1448 | ||
|
|
9fbc601870 | ||
|
|
4489646144 | ||
|
|
261b2dbdd7 | ||
|
|
224323f443 | ||
|
|
6d031f033e | ||
|
|
c38e1832dc | ||
|
|
13036da36b | ||
|
|
f5c628fbb5 | ||
|
|
5673d82b33 | ||
|
|
8c3287941a | ||
|
|
830d15f7ec | ||
|
|
4e42d96d66 | ||
|
|
ed77f48189 | ||
|
|
ed3107405f | ||
|
|
03e87697cf | ||
|
|
bccf0d3b6b | ||
|
|
36b7d1b721 | ||
|
|
2294410fb9 | ||
|
|
10140c6450 | ||
|
|
489a05663a | ||
|
|
f31a3ebdcc | ||
|
|
6360708255 | ||
|
|
24a87f3deb | ||
|
|
a8d7e6be50 | ||
|
|
b434c4ef17 | ||
|
|
0d33c0f0a8 | ||
|
|
e95aea17be | ||
|
|
d820da2ee2 | ||
|
|
692e6e1d8f | ||
|
|
6aca7e527e | ||
|
|
ae34795c2d | ||
|
|
310b352a08 | ||
|
|
b8e9ca8d0a | ||
|
|
7731c6700b | ||
|
|
563f82d265 | ||
|
|
10b24cc16e | ||
|
|
f6cd3048e1 | ||
|
|
4ad723b854 | ||
|
|
4af7a39527 | ||
|
|
0e11ae9382 | ||
|
|
a2014826e3 | ||
|
|
3a002f224d | ||
|
|
fa78e678b9 | ||
|
|
5fdcc65b5b | ||
|
|
476452c59e | ||
|
|
b0c0790b91 | ||
|
|
9d83dee14c | ||
|
|
09223c9efc | ||
|
|
d0a23b0792 | ||
|
|
d54e4a8153 | ||
|
|
57a062266f | ||
|
|
ed71c6bb6e | ||
|
|
307665d8ec | ||
|
|
297aef7bf1 | ||
|
|
161f544442 | ||
|
|
22e782fa2d | ||
|
|
7135553e42 | ||
|
|
1913ed0c11 | ||
|
|
6de41f4988 | ||
|
|
c5e744aacf | ||
|
|
983de97d7f | ||
|
|
7236c913da | ||
|
|
72ec3b51a0 | ||
|
|
3653453897 | ||
|
|
d32c1636ce | ||
|
|
a99ad81196 | ||
|
|
eec52a590c | ||
|
|
c7b8ce11e0 | ||
|
|
2156aa0877 | ||
|
|
966dec9654 | ||
|
|
0b2ab61422 | ||
|
|
092b4697a9 | ||
|
|
6c5fe836f4 | ||
|
|
40b99f7332 | ||
|
|
d45a141704 | ||
|
|
ee106a17f6 | ||
|
|
22320808fc | ||
|
|
5006a6fb34 | ||
|
|
df203f48bd | ||
|
|
0e53234d02 | ||
|
|
14a4725c94 | ||
|
|
740cb7faf8 | ||
|
|
384955777d | ||
|
|
8b80f59d3a | ||
|
|
6a6e3ad754 | ||
|
|
d15aa9372d | ||
|
|
5144f581e7 | ||
|
|
8250c5f372 | ||
|
|
ae26e0ba3d | ||
|
|
6a52591e1a | ||
|
|
81afca1efe | ||
|
|
eac59ea11f | ||
|
|
2f3a066838 | ||
|
|
3922db3aa6 | ||
|
|
9cd778534a | ||
|
|
69282bab89 | ||
|
|
af08392260 | ||
|
|
a90f1f4fef | ||
|
|
781009b745 | ||
|
|
6357f9227e | ||
|
|
7dfb90d8f3 | ||
|
|
7348fdef7f | ||
|
|
e42de09504 | ||
|
|
5c842ef212 | ||
|
|
c94b397222 | ||
|
|
c013885bec | ||
|
|
4a2667015f | ||
|
|
e209ef2483 | ||
|
|
2a00791a1b | ||
|
|
dcf9e1d47e | ||
|
|
6c0612dc8f | ||
|
|
315631f67c | ||
|
|
c31c0c2c07 | ||
|
|
c7c2cffbbf | ||
|
|
e77e8441cf | ||
|
|
d95c53ed24 | ||
|
|
300b4f8d64 | ||
|
|
36977b429b | ||
|
|
0d7643ee42 | ||
|
|
145ac78de8 | ||
|
|
245603e8b9 | ||
|
|
3eab7ec09b | ||
|
|
a50261ab42 | ||
|
|
8cd8ffb659 | ||
|
|
932ab0a047 | ||
|
|
b5686f45b3 | ||
|
|
88c4cb42cd | ||
|
|
cd6a87037d | ||
|
|
deaea078ea | ||
|
|
bdfe163ecf | ||
|
|
1fb3a233a2 | ||
|
|
0c91e4c615 | ||
|
|
b138fa2c44 | ||
|
|
70c4a775e3 | ||
|
|
978e858592 | ||
|
|
eca15317ce | ||
|
|
a9961c2cc1 | ||
|
|
557cbffe48 | ||
|
|
31258a75fa | ||
|
|
f884874d43 | ||
|
|
762ccc1f31 | ||
|
|
10b3268d43 | ||
|
|
fd1c13830c | ||
|
|
4e720a0d5b | ||
|
|
6dc91ad518 | ||
|
|
ae15a043b1 | ||
|
|
3af0b4409c | ||
|
|
feb2e4b3e3 | ||
|
|
276d8c631a | ||
|
|
720c9ae2d4 | ||
|
|
ed7e246f61 | ||
|
|
0dea77b5f2 | ||
|
|
e287755f13 | ||
|
|
eef880c448 | ||
|
|
1159efde80 | ||
|
|
9507aeb28d | ||
|
|
95011e4a04 | ||
|
|
fc94d7454f | ||
|
|
9dc1f6f3a7 | ||
|
|
3c87341280 | ||
|
|
df460c6b61 | ||
|
|
fec6a5bfa6 | ||
|
|
43e3395350 | ||
|
|
535d276665 | ||
|
|
b37ce5e8cf | ||
|
|
147dfe0ea3 | ||
|
|
91eacd23ef | ||
|
|
97f3699e97 | ||
|
|
50c8c61078 | ||
|
|
ff605369be | ||
|
|
3d5b2bf7b3 | ||
|
|
92aa11be39 | ||
|
|
4e7c3419c0 | ||
|
|
feae1c9f46 | ||
|
|
293d71dea6 | ||
|
|
7d2c7a634c | ||
|
|
d78dc0e22a | ||
|
|
203612b421 | ||
|
|
f40fd295b1 | ||
|
|
b26239f1ff | ||
|
|
13c1fcf811 | ||
|
|
a7d98136c5 | ||
|
|
2a7c85c61c | ||
|
|
4ff315329c | ||
|
|
2b51f19fbd | ||
|
|
a9677cee8b | ||
|
|
46b6a816eb | ||
|
|
6ffc03e8bd | ||
|
|
ccfa7dacdf | ||
|
|
ec01ac2a50 | ||
|
|
a9e4f644a2 | ||
|
|
5087a70d5e | ||
|
|
57466f61b3 | ||
|
|
b4a5ad121b | ||
|
|
f9a563248b | ||
|
|
a4d765da14 | ||
|
|
de2f38bbd5 | ||
|
|
b45451893d | ||
|
|
2c4f005d76 | ||
|
|
30d57e11f2 | ||
|
|
d477fd4e82 | ||
|
|
06945a01ea | ||
|
|
cc75d81538 | ||
|
|
f50e79ea1e | ||
|
|
706a7e0aaa | ||
|
|
ed62fe17c5 | ||
|
|
e7290e6c62 | ||
|
|
e938c909a1 | ||
|
|
510a3d7113 | ||
|
|
c36fb2657a | ||
|
|
cc66356ad5 | ||
|
|
c4d4aa77db | ||
|
|
0daef53c87 | ||
|
|
ad98bec73e | ||
|
|
517f457182 | ||
|
|
0e92ace6f4 | ||
|
|
3675812353 | ||
|
|
4b9078e64a | ||
|
|
ca8235e1cc | ||
|
|
56b9bb7814 | ||
|
|
78116ef36f | ||
|
|
408b221bac | ||
|
|
2a11885ac3 | ||
|
|
62e4a4283f | ||
|
|
8fd49cc55e | ||
|
|
60b52b342d | ||
|
|
570ef47455 | ||
|
|
ae2d7a12ad | ||
|
|
b310a94180 | ||
|
|
45ad8bb2ad | ||
|
|
2bc89f67f8 | ||
|
|
34ba874091 | ||
|
|
9ddd103ac5 | ||
|
|
601c048ea5 | ||
|
|
237e97c323 | ||
|
|
891780bb52 | ||
|
|
c9391cf451 | ||
|
|
74b756d817 | ||
|
|
f3def8cf8a | ||
|
|
3d2af23f14 | ||
|
|
7044ce08c1 | ||
|
|
c5011a4383 | ||
|
|
d0c4251eda | ||
|
|
2e0ed44763 | ||
|
|
afea2aeec0 | ||
|
|
e121b1645f | ||
|
|
2ab56887b7 | ||
|
|
7e3a616983 | ||
|
|
e852e2972a | ||
|
|
ff7f8faf37 | ||
|
|
589b46a8c2 | ||
|
|
824f15aa29 | ||
|
|
cc3b1190fd | ||
|
|
025d4cfadb | ||
|
|
d3cc0571ce | ||
|
|
539a37e5ee | ||
|
|
e42a7ca6de | ||
|
|
609d3e3a9f | ||
|
|
038f62d535 | ||
|
|
37451b9aff | ||
|
|
00a1b656e4 | ||
|
|
e0c412a33c | ||
|
|
a2ca095c24 | ||
|
|
42ae953055 | ||
|
|
9c2d3f3693 | ||
|
|
9d8a1fdfc4 | ||
|
|
713cb4ea03 | ||
|
|
c6a9df9fe9 | ||
|
|
470d38e583 | ||
|
|
65dff7d168 | ||
|
|
d2d83a0865 | ||
|
|
aa498a7d1f | ||
|
|
d106f6382a | ||
|
|
cdff2ee697 | ||
|
|
d76756c67a | ||
|
|
4edb48fd24 | ||
|
|
860ae42513 | ||
|
|
0598a42164 | ||
|
|
9a811708ba | ||
|
|
bb63cb204b | ||
|
|
f68057fe87 | ||
|
|
fb45c442a8 | ||
|
|
acf4f0fa2a | ||
|
|
6cea3e97db | ||
|
|
e5545f5f14 | ||
|
|
24d75dfbbc | ||
|
|
5370b39fb6 | ||
|
|
84a78ce6b5 | ||
|
|
c8756c3a01 | ||
|
|
e3cb8a7291 | ||
|
|
3c11cbbdf1 | ||
|
|
9657a1e61e | ||
|
|
728b3a3a1b | ||
|
|
41bf6f3941 | ||
|
|
a02be831f2 | ||
|
|
5cfcd5789f | ||
|
|
28c34bcf7d | ||
|
|
e87a36421c | ||
|
|
479752036d | ||
|
|
c2d95725e4 | ||
|
|
9acca449e3 | ||
|
|
bd2def7882 | ||
|
|
b201f019ef | ||
|
|
1d59d29519 | ||
|
|
9582a9f2cd | ||
|
|
363104755e | ||
|
|
ab7160170d | ||
|
|
afcb8d8616 | ||
|
|
69cf5817f0 | ||
|
|
572fc66a69 | ||
|
|
6a877620e6 | ||
|
|
c7a60b9608 | ||
|
|
3feca32d13 | ||
|
|
081eb8723d | ||
|
|
a1034654a9 | ||
|
|
faa3f60b55 | ||
|
|
31345eb298 | ||
|
|
a9332495cf | ||
|
|
e4c5da3178 | ||
|
|
f3c6581fa5 | ||
|
|
d6cf419266 | ||
|
|
939804eae0 | ||
|
|
2fc8826403 | ||
|
|
298bca4042 | ||
|
|
f5d3b3af0b | ||
|
|
733295f7e6 | ||
|
|
a706729f1b | ||
|
|
e180f526fb | ||
|
|
990f929b4e | ||
|
|
07292dffa4 | ||
|
|
63b13f8cdd | ||
|
|
70c15653a9 | ||
|
|
caf381496f | ||
|
|
00b57cd4db | ||
|
|
2c32c102d4 | ||
|
|
57a6a33122 | ||
|
|
73990fb907 | ||
|
|
e3ae013875 | ||
|
|
0f031d2f0d | ||
|
|
9d61168cf2 | ||
|
|
3feca6cb41 | ||
|
|
825aa3fb51 | ||
|
|
fb2b4b337b | ||
|
|
8f672405bc | ||
|
|
a17b3d5b9c | ||
|
|
2e45ac6bf9 | ||
|
|
ab5f2179ea | ||
|
|
b8d1cd6f70 | ||
|
|
4ad0baf99a | ||
|
|
4f1c9e161a | ||
|
|
a223d910c1 | ||
|
|
2e589bef0c | ||
|
|
730426041a | ||
|
|
338e42dd2a | ||
|
|
35e3466a79 | ||
|
|
a1e3cfb577 | ||
|
|
360a092584 | ||
|
|
a9e356b927 | ||
|
|
b6abc76f3f | ||
|
|
b1589a8790 | ||
|
|
03a2168761 | ||
|
|
3989d8cee9 | ||
|
|
eae565edfa | ||
|
|
f9bf9b07d9 | ||
|
|
fb3fae8c9b | ||
|
|
3a7a806420 | ||
|
|
03f076a1bc | ||
|
|
3a4d1f03c0 | ||
|
|
96c64b4b27 | ||
|
|
4175cffa4d | ||
|
|
9e8b55391a | ||
|
|
c2c3b743b3 | ||
|
|
8de413c5b5 | ||
|
|
4791f3dcc3 | ||
|
|
760289c6cf | ||
|
|
ae528ef8d6 | ||
|
|
3e5e9932d8 | ||
|
|
e72dc20192 | ||
|
|
707dfcf579 | ||
|
|
6343d3db81 | ||
|
|
7117955126 | ||
|
|
e98c7d5ed7 | ||
|
|
7bcfd974cb | ||
|
|
f51ba69fa7 | ||
|
|
228cc9669c | ||
|
|
3a46599e79 | ||
|
|
84df30a767 | ||
|
|
58ae1359fa | ||
|
|
3d55ae1a89 | ||
|
|
c00c1002e3 | ||
|
|
8ee83a9b26 | ||
|
|
68e1287fb8 | ||
|
|
f57c5736e6 | ||
|
|
531a7ed74a | ||
|
|
4746b18ffd | ||
|
|
067eba01fc | ||
|
|
80b7a33e9f | ||
|
|
4a938b9aa8 | ||
|
|
55bfc5d2b6 | ||
|
|
dd81190ff3 | ||
|
|
3e4ef8347f | ||
|
|
b833844d09 | ||
|
|
2ace6b8ad7 | ||
|
|
2fc1b97124 | ||
|
|
21d3115346 | ||
|
|
c408cc12c8 | ||
|
|
0fd3fc2e23 | ||
|
|
ffca6573df | ||
|
|
9e7c12f9e5 | ||
|
|
df39bccf78 | ||
|
|
186d9006fc | ||
|
|
25bd8d2ada | ||
|
|
2edfaeecde | ||
|
|
331c17a7cc | ||
|
|
53936f11e0 | ||
|
|
9660b9016b | ||
|
|
87c73869af | ||
|
|
d706cc4f6b | ||
|
|
a45843fd39 | ||
|
|
656cfb3932 | ||
|
|
0f186b6b3b | ||
|
|
efc0dfddc0 | ||
|
|
667d0e6d99 | ||
|
|
763a525292 | ||
|
|
f3e54e46b5 | ||
|
|
292e974d29 | ||
|
|
8294248e2d | ||
|
|
e938b4ecd5 | ||
|
|
bf19cd1351 | ||
|
|
ae197dac8f | ||
|
|
5ea4fdf05e | ||
|
|
33eed09c84 | ||
|
|
dd79587a63 | ||
|
|
e73a920154 | ||
|
|
9c4db41b76 | ||
|
|
7ba20c78b1 | ||
|
|
a6c29e3e5d | ||
|
|
775915e6b2 | ||
|
|
1d0c7e1c73 | ||
|
|
9c0bdb4848 | ||
|
|
13432292cc | ||
|
|
62d289a270 | ||
|
|
b893f28145 | ||
|
|
ea816859b6 | ||
|
|
9508797ab9 | ||
|
|
36aa610b8e | ||
|
|
8822c2892e | ||
|
|
9626995b1c | ||
|
|
ddfbfdcbb3 | ||
|
|
b2a02fb78c | ||
|
|
29e3bd4f9f | ||
|
|
c522b90150 | ||
|
|
454b594b96 | ||
|
|
2cc2258682 | ||
|
|
529486feb3 | ||
|
|
faf644d9dd | ||
|
|
fb3de109e0 | ||
|
|
c34ba46563 | ||
|
|
b783ec2949 | ||
|
|
982b40d6e8 | ||
|
|
0840a79024 | ||
|
|
93548d644f | ||
|
|
ce07c81bf7 | ||
|
|
34afdcac1a | ||
|
|
8af1727c3d | ||
|
|
08c295b704 | ||
|
|
7eb3ca32bb | ||
|
|
d22b35aacc | ||
|
|
e065d71aed | ||
|
|
b482f6649d | ||
|
|
d6ca3f9f3c | ||
|
|
d741f6ec92 | ||
|
|
4399428fdf | ||
|
|
e0e25fc6d3 | ||
|
|
25e9c1a885 | ||
|
|
b59fd06381 | ||
|
|
b540ac44df | ||
|
|
1e08211d17 | ||
|
|
6c739d9c43 | ||
|
|
9a2e0519d3 | ||
|
|
47e7632399 | ||
|
|
58e319b58e | ||
|
|
17a658e373 | ||
|
|
1c0b464bed | ||
|
|
fcb61dcc3b | ||
|
|
6d4ff9d625 | ||
|
|
642821db9f | ||
|
|
dafc3161c5 | ||
|
|
34712ba1f0 | ||
|
|
47304c7baf | ||
|
|
37e8912fa5 | ||
|
|
274da95b71 | ||
|
|
59138c9f5f | ||
|
|
b555595f64 | ||
|
|
b2daf6b58e | ||
|
|
e636c4a30b | ||
|
|
4005b01a85 | ||
|
|
063245e665 | ||
|
|
e3bdd5bb45 | ||
|
|
122fd018d0 | ||
|
|
febc75da17 | ||
|
|
eff8bbabc5 | ||
|
|
c7f4fa0165 | ||
|
|
a0312f8b52 | ||
|
|
43be8d6662 | ||
|
|
8570677388 | ||
|
|
081548b35a | ||
|
|
fd14638c83 | ||
|
|
87fa1ee107 | ||
|
|
f8879f7b68 | ||
|
|
39c8b9a4a1 | ||
|
|
812cdd970c | ||
|
|
3da0984ae0 | ||
|
|
1961e76699 | ||
|
|
62fc84912e | ||
|
|
eb299b73d2 | ||
|
|
b35ff16faa | ||
|
|
094173fe02 | ||
|
|
fdfede5069 | ||
|
|
a24ee01b37 | ||
|
|
92f1d53e48 | ||
|
|
f81aaea837 | ||
|
|
f1ba810765 | ||
|
|
b704499308 | ||
|
|
0cd432a52f | ||
|
|
0afef6c587 | ||
|
|
4219e47529 | ||
|
|
8320bf28ce | ||
|
|
ba5b82115f | ||
|
|
bb9d47c7e0 | ||
|
|
f1196e17ff | ||
|
|
ab962bfe7d | ||
|
|
07854ba9fc | ||
|
|
f2cd1ba0f2 | ||
|
|
6b8595750a | ||
|
|
aa451946a5 | ||
|
|
0195aae6ff | ||
|
|
6b8c3949a0 | ||
|
|
bc6e2ab8d2 | ||
|
|
72f4f47e8f | ||
|
|
ecb5729227 | ||
|
|
fd8e2d9a17 | ||
|
|
4975084eef | ||
|
|
d1ea1d7658 | ||
|
|
626484d8cb | ||
|
|
864753942f | ||
|
|
66c2db86d9 | ||
|
|
e30868c072 | ||
|
|
c5c08aa4f9 | ||
|
|
bc67378bc9 | ||
|
|
649d6e0494 | ||
|
|
1a8f6351ef | ||
|
|
58426cd0ee | ||
|
|
3d0d7048aa | ||
|
|
9033c1ade0 | ||
|
|
bf9ba90ed3 | ||
|
|
07aa38e162 | ||
|
|
181161250a | ||
|
|
019b270242 | ||
|
|
cf5fd9753c | ||
|
|
a465fcf935 | ||
|
|
2d01e0af85 | ||
|
|
3fcd118af8 | ||
|
|
cdf27b4d48 | ||
|
|
ab993e36b9 | ||
|
|
9277db4dcd | ||
|
|
9b76577bb9 | ||
|
|
c57fd6e539 | ||
|
|
2ffe42741d | ||
|
|
5d97746d9d | ||
|
|
e22a84c8ad | ||
|
|
2b12baa166 | ||
|
|
2ecb522b44 | ||
|
|
6527ae8613 | ||
|
|
10aa29e68b | ||
|
|
d944ddccd5 | ||
|
|
ef469ce419 | ||
|
|
c1b3040f8a | ||
|
|
bf0c818c28 | ||
|
|
92f7464477 | ||
|
|
034944877c | ||
|
|
694b93ebe8 | ||
|
|
29e5be4e01 | ||
|
|
02e23c885a | ||
|
|
74d5473a8b | ||
|
|
02e1a28e89 | ||
|
|
2c61ab26c8 | ||
|
|
eeb14cf752 | ||
|
|
e97e708c01 | ||
|
|
aa8d60cc1d | ||
|
|
62259ab331 | ||
|
|
c010d1d17a | ||
|
|
91e3f23d4e | ||
|
|
1d6b970d7b | ||
|
|
430b2db9aa | ||
|
|
4a480d71cc | ||
|
|
1537016942 | ||
|
|
1023e80fa6 | ||
|
|
91e1959833 | ||
|
|
0add66f607 | ||
|
|
273e34de2c | ||
|
|
7adc06332e | ||
|
|
271fbc4b02 | ||
|
|
f6acbaab21 | ||
|
|
8715251e9b | ||
|
|
8c5660173c | ||
|
|
52ec124cd6 | ||
|
|
cf423ff5eb | ||
|
|
bd24711785 | ||
|
|
304b5fac6e | ||
|
|
9cfbfe48ea | ||
|
|
9e32098b55 | ||
|
|
2a1814254c | ||
|
|
e0633bfa0d | ||
|
|
a33891a5a9 | ||
|
|
8504e4a155 | ||
|
|
390a380763 | ||
|
|
c01f80c248 | ||
|
|
7427c45c81 | ||
|
|
5175ac1dfe | ||
|
|
9e17dc73fe | ||
|
|
8843e562da | ||
|
|
5146a6c3d8 | ||
|
|
c22ee282e9 | ||
|
|
0b5ac811c4 | ||
|
|
096399fb0a | ||
|
|
e703490a1e | ||
|
|
6cd180771a | ||
|
|
beb861d707 | ||
|
|
d4f299aa40 | ||
|
|
c2b1433ed6 | ||
|
|
6157557a12 | ||
|
|
0443814f0c | ||
|
|
a5537a5664 | ||
|
|
ebb7537b2f | ||
|
|
efd1461102 | ||
|
|
7022cf1f64 | ||
|
|
6689e68a7a | ||
|
|
99423dd04f | ||
|
|
50344f3cd5 | ||
|
|
b410879ca9 | ||
|
|
d904af2d2c | ||
|
|
d28d498e03 | ||
|
|
c81cfa3af3 | ||
|
|
705932825a | ||
|
|
6ace7e72ab | ||
|
|
4d972791af | ||
|
|
3187625dc5 | ||
|
|
fb5447c92f | ||
|
|
55c24f884e | ||
|
|
5795e40573 | ||
|
|
f7652ec3c3 | ||
|
|
60b4ed034a | ||
|
|
189634657e | ||
|
|
dfcacdc392 | ||
|
|
25a22d7da9 | ||
|
|
9e574aa6c5 | ||
|
|
3144e7a721 | ||
|
|
bc3f13d360 | ||
|
|
010665284b | ||
|
|
6f47cc50b0 | ||
|
|
1172adefb4 | ||
|
|
4f38a2d8b1 | ||
|
|
d75448c548 | ||
|
|
9c69b7129c | ||
|
|
4ea82f85ba | ||
|
|
3132294cce | ||
|
|
6f278f5bb5 | ||
|
|
92e3f15c01 | ||
|
|
1f5a76e481 | ||
|
|
4445a90437 | ||
|
|
0afa8bcdcd | ||
|
|
55d7e0d5cb | ||
|
|
4b07c3b5c9 | ||
|
|
a319e2b0bc | ||
|
|
924459f84b | ||
|
|
396b23ee88 | ||
|
|
775bc954e3 | ||
|
|
6fabb6c111 | ||
|
|
e328e6a4c2 | ||
|
|
443245280f | ||
|
|
a248151243 | ||
|
|
e05e75ed28 | ||
|
|
4d450e6b85 | ||
|
|
b014592381 | ||
|
|
1d1c9400f8 | ||
|
|
97d4bcfba9 | ||
|
|
ed19ed750c | ||
|
|
93e7b69f88 | ||
|
|
af58db914b | ||
|
|
0c9e6379d6 | ||
|
|
311b36b977 | ||
|
|
d12fd0e16f | ||
|
|
a7998a1d29 | ||
|
|
231635f86d | ||
|
|
5574085a01 | ||
|
|
5d53899f89 | ||
|
|
97c8114fb7 | ||
|
|
28d825c681 | ||
|
|
cede800726 | ||
|
|
53c1f9fd0b | ||
|
|
796f16a003 | ||
|
|
738d84045d | ||
|
|
de1028e141 | ||
|
|
f9d622a8d8 | ||
|
|
fbdf1df9d0 | ||
|
|
09baee8d73 | ||
|
|
84d0ec5e9c | ||
|
|
4d9bc435fc | ||
|
|
7d08e65e64 | ||
|
|
6e19bf6add | ||
|
|
96ad18a902 | ||
|
|
58983588a4 | ||
|
|
9401a8d483 | ||
|
|
6019303f2c | ||
|
|
28fd6a7236 | ||
|
|
e8d48eb5f0 | ||
|
|
d91534236f | ||
|
|
4d3682c0ef | ||
|
|
45c6cf22ff | ||
|
|
c56b30337f | ||
|
|
ab128ba6bb | ||
|
|
b2bbe75f6d | ||
|
|
0f095ceeca | ||
|
|
331dea37cf | ||
|
|
af72df8a4f | ||
|
|
4f56dfc276 | ||
|
|
2817d83c56 | ||
|
|
571b876626 | ||
|
|
60867671aa | ||
|
|
1d46599a1f | ||
|
|
04734a7dd9 | ||
|
|
37351f9fdf | ||
|
|
79fba3a402 | ||
|
|
7c9cc230ac | ||
|
|
ff2f6b035a | ||
|
|
b16d538942 | ||
|
|
abd94d6858 | ||
|
|
6ab2d8663e | ||
|
|
2576fa2a87 | ||
|
|
d0627d5370 | ||
|
|
a192129ff9 | ||
|
|
ee6b87c3a9 | ||
|
|
7fccd41ced | ||
|
|
b95e3269ea | ||
|
|
38c0023e23 | ||
|
|
1efc9a90cb | ||
|
|
de04006afc | ||
|
|
d484bff407 | ||
|
|
7cd5f786b2 | ||
|
|
65c94c9f12 | ||
|
|
c3e5b1cabc | ||
|
|
ad97c5638f | ||
|
|
c9d05642e2 | ||
|
|
3410128d6f | ||
|
|
b7a8d4d09d | ||
|
|
919047cfde | ||
|
|
fbaf98c117 | ||
|
|
5d96331ef5 | ||
|
|
587d457712 | ||
|
|
979b283928 | ||
|
|
7c70dea398 | ||
|
|
3f04d0e23b | ||
|
|
d953f6ac3e | ||
|
|
e5e78755ae | ||
|
|
52c1016b45 | ||
|
|
c0a3ba6a92 | ||
|
|
6be7b87173 | ||
|
|
35a63ff5ab | ||
|
|
375750a6ff | ||
|
|
ea8ac3c293 | ||
|
|
5507ccd728 | ||
|
|
b96c8c2516 | ||
|
|
e04240870d | ||
|
|
506cd00b27 | ||
|
|
cdd40facec | ||
|
|
6222610836 | ||
|
|
16b5bdf37d | ||
|
|
6e39fe9bef | ||
|
|
78c2f1aaa1 | ||
|
|
947b166935 | ||
|
|
337afcf075 | ||
|
|
77e67bb714 | ||
|
|
dbcac419de | ||
|
|
475e5eb3e7 | ||
|
|
12f05e8570 | ||
|
|
527a01bebb | ||
|
|
ffe2ab7737 | ||
|
|
2fd8346dc5 | ||
|
|
fd4cb58f0c | ||
|
|
d2647d5f0c | ||
|
|
e27c106c33 | ||
|
|
e7840b05a6 | ||
|
|
3484c6549e | ||
|
|
30054aec04 | ||
|
|
fae274062a | ||
|
|
3b77af2a10 | ||
|
|
a4b757fa95 | ||
|
|
9d7e1d4f63 | ||
|
|
858e62f732 | ||
|
|
50a775fc72 | ||
|
|
feaefdb952 | ||
|
|
755ad00619 | ||
|
|
c6e440ca72 | ||
|
|
f34f786bc6 | ||
|
|
b2e7a4d63c | ||
|
|
f100385c79 | ||
|
|
0081ed3716 | ||
|
|
065115d2bf | ||
|
|
ed6518df9c | ||
|
|
f7a52d70d6 | ||
|
|
72d978ed55 | ||
|
|
90889454a0 | ||
|
|
d1c90f8434 | ||
|
|
8622926a4c | ||
|
|
2de35424b5 | ||
|
|
d603953a77 | ||
|
|
d0e3e238b8 | ||
|
|
83dfb5e9b2 | ||
|
|
727d926bf6 | ||
|
|
fe9f4bb17e | ||
|
|
1afd876d99 | ||
|
|
6fee1ce5ea | ||
|
|
7828c372e3 | ||
|
|
e02047345c | ||
|
|
db76ec3266 | ||
|
|
06de1c1fb6 | ||
|
|
a662787b9c | ||
|
|
bc4c0811d1 | ||
|
|
893cbdaa0c | ||
|
|
44c5e30f4d | ||
|
|
b81ec3e634 | ||
|
|
b48e4e63d9 | ||
|
|
dce1875de4 | ||
|
|
3392bf5ed7 | ||
|
|
99de97b2e1 | ||
|
|
d30b616b26 | ||
|
|
aa7cb55c8e | ||
|
|
9e756c6772 | ||
|
|
8ee50adf4d | ||
|
|
4c28753e6a | ||
|
|
1cdb8b724c | ||
|
|
6a2efac566 | ||
|
|
8c1b926008 | ||
|
|
c539e5f70d | ||
|
|
154ec5000e | ||
|
|
80c5103ce0 | ||
|
|
15c0ff2e75 | ||
|
|
a404734794 | ||
|
|
f962c8e8a9 | ||
|
|
bb20937bc9 | ||
|
|
3026e21b46 | ||
|
|
92b9faa063 | ||
|
|
a1894871aa | ||
|
|
bd03a7164e | ||
|
|
9891620188 | ||
|
|
afa0726bdf | ||
|
|
1a927e5420 | ||
|
|
5f4db76cbf | ||
|
|
4092324fea | ||
|
|
efc7377942 | ||
|
|
94f88855de | ||
|
|
73afafb605 | ||
|
|
fe556a0707 | ||
|
|
79d0c6dd14 | ||
|
|
8e9349b58b | ||
|
|
22710adbe3 | ||
|
|
b8a1b39bba | ||
|
|
ef341cb94e | ||
|
|
cda001e5c6 | ||
|
|
254c4768eb | ||
|
|
8f0125587f | ||
|
|
1aaf6f8fab | ||
|
|
1d56d55487 | ||
|
|
205ca95217 | ||
|
|
f9a29ed3ef | ||
|
|
f3420dc66f | ||
|
|
6d581c8e7c | ||
|
|
10bb206d51 | ||
|
|
a2747b6e33 | ||
|
|
b3b0ae59ab | ||
|
|
d99434000f | ||
|
|
2a164ddf49 | ||
|
|
12a0660e5b | ||
|
|
2502460143 | ||
|
|
c31631b69f | ||
|
|
711cf7c690 | ||
|
|
3669ed7645 | ||
|
|
41ebf3c705 | ||
|
|
6f359be64d | ||
|
|
d472a321de | ||
|
|
eda9f0f7f9 | ||
|
|
d9c729d9ab | ||
|
|
78b0909f02 | ||
|
|
61a9b6c56f | ||
|
|
a6bc2a1f8f | ||
|
|
503c9128d4 | ||
|
|
1b4bba051e | ||
|
|
c14fb857bc | ||
|
|
be528ff282 | ||
|
|
fb9461834a | ||
|
|
0217d84721 | ||
|
|
f1744c3f69 | ||
|
|
a79373c8cd | ||
|
|
d2975f0c20 | ||
|
|
d782fb8eab | ||
|
|
bdf152e5d6 | ||
|
|
40edad1c99 | ||
|
|
7239aae8aa | ||
|
|
81c789b713 | ||
|
|
744da99f30 | ||
|
|
112ce08fde | ||
|
|
3fd01648a8 | ||
|
|
619ec15920 | ||
|
|
a6a097b15f | ||
|
|
450a2f6975 | ||
|
|
e006043290 | ||
|
|
34db50280e | ||
|
|
6268fb68f8 | ||
|
|
9a310a0438 | ||
|
|
821ab6a16f | ||
|
|
f9e78bf280 | ||
|
|
83488ca878 | ||
|
|
53d748a7bd | ||
|
|
6cb2b77f69 | ||
|
|
d133287e7e | ||
|
|
3ef14a6fe6 | ||
|
|
f84096488a | ||
|
|
884a822408 | ||
|
|
fe000cec33 | ||
|
|
3202dcb1b0 | ||
|
|
eb20ebff6e | ||
|
|
a0e8aa5c54 | ||
|
|
00e2642aa9 | ||
|
|
0396a617c8 | ||
|
|
9a55e4b72e | ||
|
|
60a88bc3d5 | ||
|
|
e73c41f3c6 | ||
|
|
4b561de04c | ||
|
|
35ec5b98cf | ||
|
|
4bd0052d9f | ||
|
|
545b0c79cb | ||
|
|
c66bf89883 | ||
|
|
60fa6d7806 | ||
|
|
194306ac4f | ||
|
|
f29305b0af | ||
|
|
1ac74dbe36 | ||
|
|
c76953294c | ||
|
|
1458184fc2 | ||
|
|
14b63f4627 | ||
|
|
9492455bc3 | ||
|
|
32ad99128e | ||
|
|
64f1fd4380 | ||
|
|
6386fc7a46 | ||
|
|
29425b48fc | ||
|
|
a9ba11a0ff | ||
|
|
d13b24263e | ||
|
|
0f13f2edb9 | ||
|
|
a042db27e8 | ||
|
|
ff619da2b7 | ||
|
|
93e6d388b6 | ||
|
|
173d153b82 | ||
|
|
0ddb3a4f75 | ||
|
|
9193b9be19 | ||
|
|
79970cf558 | ||
|
|
5d17ef7039 | ||
|
|
7e0aa5b93b | ||
|
|
fd32413ba1 | ||
|
|
22b277a4ad | ||
|
|
da65e5663f | ||
|
|
76d5ee1ed8 | ||
|
|
e7d68b5052 | ||
|
|
1a17b01f0c | ||
|
|
d7fcd8c761 | ||
|
|
5e0cf3a2d2 | ||
|
|
636e38a6d7 | ||
|
|
17d7e94138 | ||
|
|
49af44d4ca | ||
|
|
588b157005 | ||
|
|
b5f926ef96 | ||
|
|
2e110a7a78 | ||
|
|
f710e5c76c | ||
|
|
bedcc9a923 | ||
|
|
a554c0a9e1 | ||
|
|
ca2b3624e5 | ||
|
|
87947b3de6 | ||
|
|
160e2574d4 | ||
|
|
a534387593 | ||
|
|
004dec20d3 | ||
|
|
68c0faf527 | ||
|
|
6bd64b2863 | ||
|
|
fc96d200ed | ||
|
|
7e30c2acc6 | ||
|
|
541ead2136 | ||
|
|
6388fedd70 | ||
|
|
f34c342db3 | ||
|
|
1883d35779 | ||
|
|
15793cad26 | ||
|
|
5e3d5a4be2 | ||
|
|
5f1c1f9f2e | ||
|
|
a4af2a53ac | ||
|
|
9bbddc08e6 | ||
|
|
b0be1fd012 | ||
|
|
21303346b1 | ||
|
|
bd7531e5b7 | ||
|
|
a0184ac002 | ||
|
|
4d438ccab4 | ||
|
|
48e6144568 | ||
|
|
e028503c9e | ||
|
|
fcb3d78cc4 | ||
|
|
b976a3caa8 | ||
|
|
cc6a4cd125 | ||
|
|
6d7f98c55b | ||
|
|
d80ddcabea | ||
|
|
4c44b61a9d | ||
|
|
9eb7bebe80 | ||
|
|
1f634f5877 | ||
|
|
f78d673e02 | ||
|
|
a5d405b083 | ||
|
|
405ce756a2 | ||
|
|
31190ec826 | ||
|
|
9b3c714390 | ||
|
|
2e76b122a2 | ||
|
|
14d75223e2 | ||
|
|
bc3e82bcbf | ||
|
|
10d88da558 | ||
|
|
4dde45b6be | ||
|
|
00eeb2503a | ||
|
|
4f70de3aa9 | ||
|
|
486cf2e355 | ||
|
|
21841c0022 | ||
|
|
d1f7f02fa0 | ||
|
|
fbbab3e7d6 | ||
|
|
7248b5c398 | ||
|
|
96de18ea7b | ||
|
|
198bd9a64c | ||
|
|
9324faa32f | ||
|
|
769c25170f | ||
|
|
9e073356b2 | ||
|
|
14ff124674 | ||
|
|
f959aa24cc | ||
|
|
7d058df54d | ||
|
|
c72819de38 | ||
|
|
bad6232b9a | ||
|
|
d0e4a52ba9 | ||
|
|
2b9883c254 | ||
|
|
00e48b7c93 | ||
|
|
20cf802d3d | ||
|
|
b07a739619 | ||
|
|
3a09df6c14 | ||
|
|
aa1b55c182 | ||
|
|
e8dba6bd5e | ||
|
|
c76a7dcb6b | ||
|
|
e7f3b424e4 | ||
|
|
0198f446d4 | ||
|
|
c2f261b143 | ||
|
|
8c4e0e9068 | ||
|
|
954b0079a4 | ||
|
|
c8506eb143 | ||
|
|
a48be912b4 | ||
|
|
639755ea2b | ||
|
|
dc838776ec | ||
|
|
d7fb701983 | ||
|
|
92d4ba8a86 | ||
|
|
48d5732c16 | ||
|
|
71bbfb4fdd | ||
|
|
782f2d1fea | ||
|
|
25021a866d | ||
|
|
3e3dbd7fdb | ||
|
|
53d545e114 | ||
|
|
00a54ac0ee | ||
|
|
cbc89617dc | ||
|
|
e139c1371e | ||
|
|
358f9eb970 | ||
|
|
765a6e5d84 | ||
|
|
d4ee2fb7bc | ||
|
|
5958dec09b | ||
|
|
99dfb0afeb | ||
|
|
5367c71784 | ||
|
|
7dead5b14d | ||
|
|
2bb60ea7a2 | ||
|
|
4b7d76c150 | ||
|
|
8b02f39557 | ||
|
|
a2f075f4fb | ||
|
|
8a70f37446 | ||
|
|
47abebd1f2 | ||
|
|
2e02f8a1c8 | ||
|
|
49c0fbfd78 | ||
|
|
37bad29350 | ||
|
|
58c29a8248 | ||
|
|
cbc3875288 | ||
|
|
e43e3ffcbc | ||
|
|
57ca428a65 | ||
|
|
b2ffb626da | ||
|
|
a1cb605f37 | ||
|
|
c7b2a30f24 | ||
|
|
da24e28a92 | ||
|
|
c52b661c87 | ||
|
|
ca212fb038 | ||
|
|
44039f9a82 | ||
|
|
bcee90ef06 | ||
|
|
e712bff7b3 | ||
|
|
57fa378f4d | ||
|
|
6cbf536fa8 | ||
|
|
13f492285e | ||
|
|
60968b84c6 | ||
|
|
114bfc409f | ||
|
|
871a1e5b5b | ||
|
|
7a3212cf36 | ||
|
|
82dda02b12 | ||
|
|
76620cc9b5 | ||
|
|
dba9500825 | ||
|
|
62cac29071 | ||
|
|
07e095aa9a | ||
|
|
c8a47efde2 | ||
|
|
3ad8274ad8 | ||
|
|
64219b9343 | ||
|
|
a6fe3e59d4 | ||
|
|
0231de6086 | ||
|
|
22a2954eb8 | ||
|
|
a884f6fea2 | ||
|
|
6ac0ad6fbf | ||
|
|
15393198be | ||
|
|
c56c91afce | ||
|
|
4a66c1ee73 | ||
|
|
b9bdeeae19 | ||
|
|
c14a2044cd | ||
|
|
72aef53c7e | ||
|
|
c567a2678a | ||
|
|
242b30cee0 | ||
|
|
6c9baf3cba | ||
|
|
534a2fded7 | ||
|
|
2fc6bebacc | ||
|
|
e761c5e0f6 | ||
|
|
1a00f4b844 | ||
|
|
ab7a049512 | ||
|
|
bffa6eb6ce | ||
|
|
cefde649ba | ||
|
|
89c41818f0 | ||
|
|
f53cef6262 | ||
|
|
3064c0ec2a | ||
|
|
1e268b3eae | ||
|
|
7228053261 | ||
|
|
d6d9340304 | ||
|
|
2bf4a0fe1f | ||
|
|
f9725cb06a | ||
|
|
3c20bcf6f2 | ||
|
|
03db847d48 | ||
|
|
8b71444341 | ||
|
|
ce20a86072 | ||
|
|
c768755ae4 | ||
|
|
ef3d799411 | ||
|
|
0ba4b85a91 | ||
|
|
ad016b2a8e | ||
|
|
18c7a82ce6 | ||
|
|
dd9fd8e345 | ||
|
|
1318f399e6 | ||
|
|
6ed768cacc | ||
|
|
7d01356be7 | ||
|
|
72e51b65a2 | ||
|
|
b282f789fb | ||
|
|
a578e27c82 | ||
|
|
e23b3172c9 | ||
|
|
5e0713fb3e | ||
|
|
aafa0f74d5 | ||
|
|
40000cfa44 | ||
|
|
3865eb8928 | ||
|
|
7bf26a55be | ||
|
|
15e5dacc51 | ||
|
|
2de87accf5 | ||
|
|
78764319c0 | ||
|
|
cf86af1284 | ||
|
|
22c86747a1 | ||
|
|
39b18ff6dd | ||
|
|
f833ec6960 | ||
|
|
cb4a0655a3 | ||
|
|
8354d921f3 | ||
|
|
d178803d86 | ||
|
|
4aadff8ab3 | ||
|
|
cf849d6412 | ||
|
|
5c4429858b | ||
|
|
996dad96c1 | ||
|
|
55c08373d0 | ||
|
|
aca9286d63 | ||
|
|
c7325e3c25 | ||
|
|
61b3bcbb4f | ||
|
|
758b5b54cf | ||
|
|
94c8bab4ec | ||
|
|
7ac3041493 | ||
|
|
4be41e47f4 | ||
|
|
f782bc11cc | ||
|
|
be8d64e29e | ||
|
|
e22a4de007 | ||
|
|
fde40f4f51 | ||
|
|
e2b19d72bc | ||
|
|
a0eac497c6 | ||
|
|
f2ef14c239 | ||
|
|
2b8514b495 | ||
|
|
e9e8d9881f | ||
|
|
3975401205 | ||
|
|
6da0952e5d | ||
|
|
45acee4617 | ||
|
|
ff5096cd47 | ||
|
|
4cd97e7ff8 | ||
|
|
df86411cb7 | ||
|
|
905501f22f | ||
|
|
c48e5f1dbf | ||
|
|
0a956f0234 | ||
|
|
ae73764173 | ||
|
|
2f8e5c4b33 | ||
|
|
a542afd194 | ||
|
|
a8c9347591 | ||
|
|
dc46f8f166 | ||
|
|
6eb3551665 | ||
|
|
3829b26cc8 | ||
|
|
973b71eb80 | ||
|
|
1b7fd6a00b | ||
|
|
daa1ee3e0c | ||
|
|
35eb23e3af | ||
|
|
0c16a5c991 | ||
|
|
926dd43ce6 | ||
|
|
499bf1ee14 | ||
|
|
9c507ba631 | ||
|
|
2f307e2803 | ||
|
|
2c21a34d07 | ||
|
|
2e3ecb1c4e | ||
|
|
e21bf82de1 | ||
|
|
d837d8950c | ||
|
|
b17e0d45ae | ||
|
|
f5af1ff193 | ||
|
|
e77513d44b | ||
|
|
556e249b78 | ||
|
|
ce0e83070a | ||
|
|
d0747b9f7b | ||
|
|
eb3ccf7b1e | ||
|
|
2e619d3fb8 | ||
|
|
9d128e505f | ||
|
|
77479a3e2d | ||
|
|
353bd1d5fe | ||
|
|
ed11dd0d22 | ||
|
|
189fc0e3f2 | ||
|
|
d5c7464570 | ||
|
|
ce4c803b97 | ||
|
|
c6dbe3830e | ||
|
|
09a1b85e86 | ||
|
|
82cc9ebb09 | ||
|
|
820a4081a1 | ||
|
|
09eb4bdde8 | ||
|
|
c9c6bf1cef | ||
|
|
ff67a6d3d2 | ||
|
|
c110c4bb5e | ||
|
|
e2be203088 | ||
|
|
316de3e875 | ||
|
|
22c92043a1 | ||
|
|
cd4388d574 | ||
|
|
9449606adc | ||
|
|
4101ae21d1 | ||
|
|
15ffade573 | ||
|
|
8148d42ba7 | ||
|
|
0f08e63b6e | ||
|
|
39b5ce8210 | ||
|
|
a364aa7dea | ||
|
|
e5fc770369 | ||
|
|
da05994d0f | ||
|
|
18ef1e88fd | ||
|
|
7e5f7dd30b | ||
|
|
decad8f65c | ||
|
|
6edaf16db8 | ||
|
|
cd8f5a1b94 | ||
|
|
586b0f152b | ||
|
|
d06ca5cdbd | ||
|
|
ae1f08e506 | ||
|
|
53ef0f83d6 | ||
|
|
efcf2680ec | ||
|
|
ba4f976e3b | ||
|
|
757b23356c | ||
|
|
fa90ff5bef | ||
|
|
60262291ce | ||
|
|
515e27b489 | ||
|
|
ed268053f3 | ||
|
|
f68d4d3e24 | ||
|
|
00dd18302f | ||
|
|
3cba22ae22 | ||
|
|
ac89af9850 | ||
|
|
83e17861bd | ||
|
|
67c5cbf77f | ||
|
|
1e69fabe2c | ||
|
|
9283c4ede2 | ||
|
|
95b9f1a1fd | ||
|
|
99bab6cae9 | ||
|
|
57490cbe92 | ||
|
|
6d8b49c36c | ||
|
|
b7a4efa6b6 | ||
|
|
dac21cf807 | ||
|
|
bacaadfd86 | ||
|
|
ea55bde395 | ||
|
|
b277b020f7 | ||
|
|
1415f21c51 | ||
|
|
576d81a7ec | ||
|
|
0e5fb5b468 | ||
|
|
8daf217795 | ||
|
|
29a55c1e37 | ||
|
|
964a423879 | ||
|
|
addd27108f | ||
|
|
dc91e9d7b7 | ||
|
|
c80e09d956 | ||
|
|
6a2681ab56 | ||
|
|
e5c98ba2d1 | ||
|
|
1b9b7a2671 | ||
|
|
1a3362f1cd | ||
|
|
9009667c5a | ||
|
|
a59023575b | ||
|
|
b2b1ca2934 | ||
|
|
afd1bbb58a | ||
|
|
de655a105a | ||
|
|
2c1c0b2544 | ||
|
|
ccf5318e4c | ||
|
|
7322abfd55 | ||
|
|
09518bbac2 | ||
|
|
b6a831ec91 | ||
|
|
378cd622c4 | ||
|
|
939e3e59cf | ||
|
|
22e82b1c7f | ||
|
|
e495917356 | ||
|
|
cf4b58cab2 | ||
|
|
0a96014484 | ||
|
|
3e8fd9d6e6 | ||
|
|
a06d29863d | ||
|
|
de24e23127 | ||
|
|
9417e1aa49 | ||
|
|
1f8d26a215 | ||
|
|
e4a0b60f1e | ||
|
|
19612e4236 | ||
|
|
af1e9c609b | ||
|
|
5d18c71475 | ||
|
|
20fc9ae343 | ||
|
|
2e8b9145b7 | ||
|
|
859dec8410 | ||
|
|
8f13d2c856 | ||
|
|
d2a226852b | ||
|
|
9c7a05f616 | ||
|
|
a6e59936e2 | ||
|
|
e3d95eafe1 | ||
|
|
e30568ecd5 | ||
|
|
9ac8ea8f93 | ||
|
|
1bac018961 | ||
|
|
1aba91d6f3 | ||
|
|
fd750558ac | ||
|
|
f2cb7d1e8b | ||
|
|
7870fb06a2 | ||
|
|
9adff7572e | ||
|
|
d50da89403 | ||
|
|
8ddfb93f7a | ||
|
|
b718aa9513 | ||
|
|
7782021c12 | ||
|
|
73b2a85eea | ||
|
|
d34f51a9d0 | ||
|
|
0a2abc080f | ||
|
|
9d1786ab67 | ||
|
|
fb6b4899cd | ||
|
|
0c7549819a | ||
|
|
fd8c58e837 | ||
|
|
5c74be339a | ||
|
|
c88c821002 | ||
|
|
f20f83b936 | ||
|
|
88cc3145c4 | ||
|
|
5d1bf5f624 | ||
|
|
9fa998709d | ||
|
|
0f90626a49 | ||
|
|
40108d5201 | ||
|
|
0dde03cf6f | ||
|
|
83fe8ea02b | ||
|
|
17876fc4b0 | ||
|
|
eca16c8474 | ||
|
|
ff0dfed2e8 | ||
|
|
db8e718002 | ||
|
|
f49f0424d5 | ||
|
|
998ddfd54b | ||
|
|
53e67de669 | ||
|
|
897bb0719a | ||
|
|
d051c84737 | ||
|
|
578df160d1 | ||
|
|
ac335a7a5c | ||
|
|
5868915f2c | ||
|
|
1054c81de3 | ||
|
|
79756602a7 | ||
|
|
8d5f446ccd | ||
|
|
b689d08e71 | ||
|
|
58ae90b574 | ||
|
|
e79444e5f7 | ||
|
|
45eb91765b | ||
|
|
933ede1220 | ||
|
|
506bd42201 | ||
|
|
b3ef0a856b | ||
|
|
732c17ad76 | ||
|
|
8ee22694f1 | ||
|
|
e3909c96bd | ||
|
|
ac97f1d61d | ||
|
|
61fbc69356 | ||
|
|
67cd659712 | ||
|
|
895a81799f | ||
|
|
097bf7d193 | ||
|
|
40cccfdfa9 | ||
|
|
71fc855940 | ||
|
|
9fc05d97d9 | ||
|
|
0a3dfa8d9a | ||
|
|
43016e389a | ||
|
|
8c4284a435 | ||
|
|
3f13c56fb9 | ||
|
|
73e2be8ac8 | ||
|
|
86f706dee7 | ||
|
|
1372a68ecf | ||
|
|
dc37ab3d7a | ||
|
|
e9de7d89fd | ||
|
|
2e4b165b42 | ||
|
|
d03b340985 | ||
|
|
90d579bbcb | ||
|
|
8067985edd | ||
|
|
2d5ed1bb83 | ||
|
|
85bcc107d4 | ||
|
|
3d4285d2dc | ||
|
|
c7282f8c04 | ||
|
|
e06612f5b3 | ||
|
|
e28655a64c | ||
|
|
1db21526e1 | ||
|
|
d709739dc0 | ||
|
|
d9a1e2fd0c | ||
|
|
ac86e415e9 | ||
|
|
5b89abb646 | ||
|
|
ed49b8b10f | ||
|
|
58d02cf4bc | ||
|
|
0ed884f5ad | ||
|
|
e278ebda5d | ||
|
|
103184c8da | ||
|
|
3159ddb0a6 | ||
|
|
fc7369f586 | ||
|
|
11c78eb85e | ||
|
|
5449728cc6 | ||
|
|
7d8e58fb4d | ||
|
|
1d46fd0279 | ||
|
|
cc99b267bb | ||
|
|
cf38f55291 | ||
|
|
1483740189 | ||
|
|
f5e91a6366 | ||
|
|
0e539c0a98 | ||
|
|
d1265c21ee | ||
|
|
9a855a7288 | ||
|
|
d331367223 | ||
|
|
156f1f50bc | ||
|
|
3acd66e5b3 | ||
|
|
876a7c1b1f | ||
|
|
444d7c006d | ||
|
|
10353c9a1d | ||
|
|
22e2be2dc8 | ||
|
|
d3e2436527 | ||
|
|
984f255d8a | ||
|
|
3f508da8e6 | ||
|
|
295ffd640b | ||
|
|
b344f9d7e4 | ||
|
|
ca49c83073 | ||
|
|
d28d74a77a | ||
|
|
1b52fde005 | ||
|
|
31d5320922 | ||
|
|
e871dad863 | ||
|
|
eee2db628a | ||
|
|
4c759913c7 | ||
|
|
4442cbae9d | ||
|
|
6d0d75ab1d | ||
|
|
3f2746cdb7 | ||
|
|
d28c696190 | ||
|
|
eba7510be8 | ||
|
|
d1442bfc93 | ||
|
|
743d941204 | ||
|
|
f82bd966e0 | ||
|
|
aedc9854c1 | ||
|
|
87c5b5bc6b | ||
|
|
9f50eff10a | ||
|
|
3b56e7ca2f | ||
|
|
f64c8082ff | ||
|
|
20be763dc1 | ||
|
|
391a956af8 | ||
|
|
2121a6a4f2 | ||
|
|
dc8f82961a | ||
|
|
d9015804c8 | ||
|
|
135bec17a8 | ||
|
|
35624786ab | ||
|
|
0688a360c7 |
@@ -0,0 +1,75 @@
|
||||
name: Deploy im-webapp production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- pro
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: production-im-webapp
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: git.olns.shop
|
||||
IMAGE: git.olns.shop/pro/im_webapp
|
||||
SERVICE: im-webapp
|
||||
DEPLOY_HOST: 145.223.69.9
|
||||
DEPLOY_USER: olns-deploy
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy-host
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u admin --password-stdin
|
||||
|
||||
- name: Build and push image
|
||||
env:
|
||||
TINODE_API_KEY: ${{ secrets.TINODE_API_KEY }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHORT_SHA="$(printf '%.12s' "$GITHUB_SHA")"
|
||||
TINODE_API_KEY="${TINODE_API_KEY#\'}"
|
||||
TINODE_API_KEY="${TINODE_API_KEY%\'}"
|
||||
docker build \
|
||||
--build-arg TINODE_API_KEY="${TINODE_API_KEY}" \
|
||||
--build-arg TINODE_API_BASE="https://im-api.yuexuan.shop/v1/im" \
|
||||
--build-arg TINODE_FILE_BASE="https://im-api.yuexuan.shop/v0/file" \
|
||||
-t "${IMAGE}:pro" -t "${IMAGE}:${SHORT_SHA}" .
|
||||
docker push "$IMAGE:pro"
|
||||
docker push "$IMAGE:$SHORT_SHA"
|
||||
|
||||
- name: Deploy Compose service
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 700 "$HOME/.ssh"
|
||||
KEY_FILE="$(mktemp "$HOME/.ssh/olns-deploy.XXXXXX")"
|
||||
cleanup() {
|
||||
shred -u "$KEY_FILE" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > "$KEY_FILE"
|
||||
chmod 600 "$KEY_FILE"
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
|
||||
chmod 600 "$HOME/.ssh/known_hosts"
|
||||
SSH_ARGS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=yes)
|
||||
printf '%s' "$REGISTRY_TOKEN" | ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"docker login git.olns.shop -u admin --password-stdin"
|
||||
ssh "${SSH_ARGS[@]}" "$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"cd /opt/olns && docker compose pull im-webapp && docker compose up -d --no-deps --wait im-webapp"
|
||||
+7
-136
@@ -1,138 +1,9 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
.DS_Store
|
||||
.git.worktree-pointer.bak
|
||||
node_modules
|
||||
.idea/
|
||||
*.code-workspace
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
# Local developer overrides — never commit real keys.
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
.claude/
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# ---- Stage 1: build static assets ----
|
||||
# The active frontend lives under web/ (Vite + React + TypeScript). The legacy
|
||||
# root-level Webpack application has been removed; production must only build web/.
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/. .
|
||||
|
||||
# VITE_TINODE_API_KEY is read by src/lib/config.ts (import.meta.env.VITE_TINODE_API_KEY) at build
|
||||
# time -- Vite only exposes env vars prefixed VITE_ to client code, so the ARG is renamed on its
|
||||
# way into the build's environment rather than passed through under its old webpack-era name.
|
||||
ARG TINODE_API_KEY=
|
||||
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
|
||||
|
||||
# The production web UI and IM REST API use different origins. Local development leaves this unset
|
||||
# and continues to use Vite's same-origin /v1/im proxy.
|
||||
ARG TINODE_API_BASE=
|
||||
ENV VITE_TINODE_API_BASE=$TINODE_API_BASE
|
||||
|
||||
# File downloads must use the canonical IM API origin. The web origin has its own independently
|
||||
# deployed path router and may otherwise send /v0/file requests to an older IM server.
|
||||
ARG TINODE_FILE_BASE=https://api.118im.com/v0/file
|
||||
ENV VITE_TINODE_FILE_BASE=$TINODE_FILE_BASE
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: serve with nginx ----
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Vite's build output is fully self-contained: index.html, public assets and
|
||||
# content-hashed JS/CSS chunks.
|
||||
COPY --from=build /app/web/dist/. ./
|
||||
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,2 +1,44 @@
|
||||
# im_webapp
|
||||
# 迅工捷使 Web
|
||||
|
||||
当前前端位于 `web/`,技术栈为 React、TypeScript 和 Vite。仓库根目录不再保留旧版 Tinode Webpack 应用及其构建产物。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd web
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
cd web
|
||||
node --test tests/*.test.mjs tests/*.test.ts
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
生产构建输出到 `web/dist/`。
|
||||
|
||||
## Docker 部署
|
||||
|
||||
根目录 `Dockerfile` 只复制并构建 `web/`:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg TINODE_API_KEY="<部署密钥>" \
|
||||
--build-arg TINODE_FILE_BASE="https://api.118im.com/v0/file" \
|
||||
-t im-webapp .
|
||||
```
|
||||
|
||||
生产环境通过 `TINODE_FILE_BASE` 固定附件下载入口;本地开发不设置该变量时,继续使用租户解析结果和 Vite 代理。
|
||||
|
||||
测试集群通过 Gitea Actions 的 `Deploy im-webapp (k3s test cluster)` 工作流手动发布。
|
||||
|
||||
## 目录
|
||||
|
||||
- `web/`:当前 Vite 前端源码、测试和静态资源
|
||||
- `docker/`:Nginx 配置
|
||||
- `docs/`:设计及实施文档
|
||||
- `.gitea/workflows/`:测试集群部署工作流
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# The webapp uses real (History API) client-side routing, so any unknown path can safely fall
|
||||
# back to index.html -- the app resolves the actual view/topic client-side from the path itself
|
||||
# once index.html loads. index.html itself is never content-hashed, so it must always be
|
||||
# revalidated -- otherwise a browser holding a cached copy would keep requesting the *previous*
|
||||
# deploy's now-deleted /assets/*.js filenames after a new deploy replaces them.
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Vite content-hashes every filename under assets/ (e.g. index-DYjopaLI.js) -- a new build never
|
||||
# reuses an old asset's URL, so these can be cached aggressively and indefinitely; a stale cached
|
||||
# copy simply never gets requested again once index.html points at the new hashed filenames.
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Web Clipboard Image Compatibility Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Broaden the new Web composer's image-paste formats without changing immediate send or native text paste.
|
||||
|
||||
**Architecture:** Extract clipboard inspection into a small dependency-free module. The React paste callback synchronously snapshots event data and calls `preventDefault()` only for an image or an image-looking fallback; asynchronous data-URL/Clipboard API decoding then feeds the existing `sendImage` path.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Vite, Node 25 built-in `node:test`, Playwright CLI.
|
||||
|
||||
---
|
||||
|
||||
## File map and boundaries
|
||||
|
||||
- Create `web/src/features/messages/clipboard-image.ts`: clipboard format selection, HTML data-image extraction, async Blob conversion, Clipboard API fallback. It must not import React, Tinode, or UI state.
|
||||
- Create `web/tests/clipboard-image.test.ts`: direct behavior tests for the helper; no new test dependency.
|
||||
- Modify `web/src/features/messages/ConversationView.tsx:939-962`: replace the narrow paste loop and reuse existing `sendImage`/error toast. Do not change `handlePickImage`, `sendImage`, drafts, or other views.
|
||||
- Modify `web/package.json`: add `test:clipboard` invoking `node --test tests/clipboard-image.test.ts`.
|
||||
|
||||
## Task 1: Clipboard extraction with test-first coverage
|
||||
|
||||
**Files:** Create `web/tests/clipboard-image.test.ts`; create `web/src/features/messages/clipboard-image.ts`; modify `web/package.json`.
|
||||
|
||||
- [ ] **Step 1: Add a failing assertion and API stubs.** In the test, import `firstImageFile`, `htmlDataImageUrl`, `shouldReadClipboard`, `fileFromDataImageUrl`, and `readClipboardImage` from `../src/features/messages/clipboard-image.ts`. Use `node:test`/`node:assert/strict`. Start with `new File(['png'], 'shot.png', {type:'image/png'})` in `items[0].getAsFile()` and assert `firstImageFile(data) === file`. Export typed stub functions returning `null`/`false` in the new helper solely so the test runs to an assertion failure.
|
||||
|
||||
```ts
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { firstImageFile } from '../src/features/messages/clipboard-image.ts';
|
||||
|
||||
test('reads an image file from clipboard items', () => {
|
||||
const file = new File(['png'], 'shot.png', { type: 'image/png' });
|
||||
const data = { items: [{ kind: 'file', type: 'image/png', getAsFile: () => file }], files: [] } as unknown as DataTransfer;
|
||||
assert.equal(firstImageFile(data), file);
|
||||
});
|
||||
```
|
||||
- [ ] **Step 2: Run `cd web && node --test tests/clipboard-image.test.ts`; confirm the first assertion fails (`actual: null`, expected File), not a loader/type error.**
|
||||
- [ ] **Step 3: Implement the smallest `firstImageFile(data: DataTransfer | null): File | null` that checks each `kind=file` item via `getAsFile()`, accepts `file.type` or an image `item.type`, then checks `data.files` even when `items` exists. If only `item.type` supplies MIME, wrap the File with that MIME. Return the first image only.**
|
||||
- [ ] **Step 4: Rerun the focused test; confirm pass. Add one failing test at a time for JPEG/BMP, empty `item.type` with valid File MIME, null `getAsFile()` followed by `files`, a non-image file, and two images selecting the first. Run red then green for each.**
|
||||
- [ ] **Step 5: Add failing HTML tests:** an `<img src="data:image/png;base64,...">` URL is returned; a remote `https:` image, plain HTML text, and a `data-src` attribute are not. Implement `htmlDataImageUrl(data)` by reading `getData('text/html')` synchronously and matching only an actual `src` attribute on an `<img>`, with `data:image/` scheme. Add a failing test that `fileFromDataImageUrl()` converts a valid data URL to an image File and rejects malformed data. Implement it with `fetch(dataUrl)`, `Blob`, and `new File(...)`; never fetch a non-data URL.
|
||||
- [ ] **Step 6: Add failing fallback-decision tests:** `shouldReadClipboard(data)` is false for plain text and ordinary HTML, true for a file/image hint without usable file or a completely empty event. Implement with `types`, `items`, and `getData('text/plain')`, without asynchronous event reads.
|
||||
- [ ] **Step 7: Add failing Clipboard API tests:** injected `read()` returning a `ClipboardItem` with `image/png` produces a File; a text-only item returns null; permission rejection propagates. Implement `readClipboardImage(clipboard?: Pick<Clipboard,'read'>): Promise<File | null>` by trying image MIME types, `getType()`, and wrapping the Blob. No navigator access inside this helper, so tests can inject a stub.
|
||||
|
||||
```ts
|
||||
const clipboard = { read: async () => [{ types: ['image/png'], getType: async () => new Blob(['png'], { type: 'image/png' }) }] } as unknown as Pick<Clipboard, 'read'>;
|
||||
const image = await readClipboardImage(clipboard);
|
||||
assert.equal(image?.type, 'image/png');
|
||||
```
|
||||
- [ ] **Step 8: Add `"test:clipboard": "node --test tests/clipboard-image.test.ts"` to `web/package.json`. Run `npm run test:clipboard`; expected all tests pass. Commit only these files with `git add web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts web/package.json && git commit -m 'test: cover clipboard image formats'`.**
|
||||
|
||||
## Task 2: Integrate with the composer
|
||||
|
||||
**Files:** Modify `web/src/features/messages/ConversationView.tsx:939-962`; test `web/tests/clipboard-image.test.ts`.
|
||||
|
||||
- [ ] **Step 1: Add a failing orchestration test:** introduce `handleImagePaste(data, preventDefault, onImage, onError, clipboard)` as a named export and test that PNG calls `preventDefault` synchronously and then `onImage`, plain text does neither, HTML data-image claims before awaiting conversion, and Clipboard API failure calls `onError`. Run this test red (first as a typed stub returning `Promise.resolve(false)`), then implement the orchestrator in `clipboard-image.ts`. It must call `preventDefault` before its first `await`, and must not catch an exception thrown by `onImage` as a clipboard-read failure.
|
||||
|
||||
```ts
|
||||
let prevented = false;
|
||||
let sent: File | null = null;
|
||||
const pending = handleImagePaste(data, () => { prevented = true; }, file => { sent = file; }, () => { throw new Error('unexpected'); });
|
||||
assert.equal(prevented, true);
|
||||
await pending;
|
||||
assert.equal(sent, file);
|
||||
```
|
||||
- [ ] **Step 2: Replace the existing `handleComposerPaste` loop with a thin call to `handleImagePaste`.** Pass `e.clipboardData`, `() => e.preventDefault()`, a callback that calls `consumePendingReply()` then the existing `sendImage(topic,file,reply).catch(...)`, a callback that calls `showError(undefined, '无法读取剪贴板图片')`, and `navigator.clipboard`. This preserves existing send behavior and does not retain the React event across an asynchronous boundary. Do not broaden to previews or multi-image sends.
|
||||
|
||||
```tsx
|
||||
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
void handleImagePaste(
|
||||
e.clipboardData,
|
||||
() => e.preventDefault(),
|
||||
(file) => {
|
||||
const reply = consumePendingReply();
|
||||
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
|
||||
},
|
||||
() => showError(undefined, '无法读取剪贴板图片'),
|
||||
navigator.clipboard,
|
||||
);
|
||||
};
|
||||
```
|
||||
- [ ] **Step 3: Run `cd web && npm run test:clipboard && npm run build && npm run lint`; all commands must exit 0. Review `git diff --check` and `git diff -- web/src/features/messages/ConversationView.tsx` for scope and native-text behavior. Commit only the touched files with `git add web/src/features/messages/ConversationView.tsx web/src/features/messages/clipboard-image.ts web/tests/clipboard-image.test.ts && git commit -m 'fix: support more clipboard image formats'`.**
|
||||
|
||||
## Task 3: Browser verification and handoff
|
||||
|
||||
**Files:** No production changes unless a targeted test finds a defect.
|
||||
|
||||
- [ ] **Step 1: Start local Vite (`cd web && npm run dev -- --host 127.0.0.1`) and use Playwright CLI on the local origin. Import `/src/features/messages/clipboard-image.ts` in the browser, paste a real PNG ClipboardItem, and verify it appears as `kind=file`, `type=image/png`, and is selected. Separately dispatch a text-only ClipboardEvent and confirm `shouldReadClipboard` is false and native default is not prevented. Test HTML data image selection and remote HTML exclusion.**
|
||||
- [ ] **Step 2: Rerun `npm run test:clipboard`, `npm run build`, `npm run lint`, and `git diff --check` fresh after any fixes. Check `git status --short` and report exact commits/files. Do not push or deploy. Explain that macOS Chromium/browser-event simulation is not a real Windows WeChat end-to-end test.**
|
||||
|
||||
## Self-review against the approved spec
|
||||
|
||||
Items/files, MIME fallback, HTML data image, Clipboard API, text passthrough, error feedback, first-image-only behavior, no remote fetch, test-first, browser verification, and no deployment are all assigned above. The only unavoidable limit is native Windows clipboard conversion before JavaScript receives the event; the final report must not claim that was verified.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Web Brand And Message Sound Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将生产 Web 品牌统一为“迅工捷使”,精简登录页,并为符合提醒条件的新消息播放用户提供的提示音。
|
||||
|
||||
**Architecture:** 继续以 `web/src/lib/config.ts` 作为显示品牌名来源,认证页和系统消息只消费该常量;品牌图标和 MP3 作为 Vite `public/` 静态资源。消息声音接入现有 `push.ts` 新消息扫描链路,在浏览器通知权限判断之前复用“本人消息、当前可见会话、会话免打扰”的过滤结果。
|
||||
|
||||
**Tech Stack:** React 19、TypeScript、Vite、Node test runner、Jiti。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 写品牌和提示音红测
|
||||
|
||||
**Files:**
|
||||
- Create: `web/tests/brand-and-notification-sound.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
创建 Node 测试,核心断言如下;可按实际导出补齐 fake `Audio` 的类型字段,但不得降低断言:
|
||||
|
||||
```js
|
||||
const webRoot = new URL('../', import.meta.url);
|
||||
assert.match(await readFile(new URL('index.html', webRoot), 'utf8'), /<title>迅工捷使<\/title>/);
|
||||
assert.doesNotMatch(await readFile(new URL('src/features/auth/LoginPage.tsx', webRoot), 'utf8'), /桌面端|下载桌面客户端|iOS \/ Android|帮助中心/);
|
||||
assert.doesNotMatch(await readFile(new URL('src/features/auth/TenantEntryPage.tsx', webRoot), 'utf8'), /MeetingIM/);
|
||||
assert.doesNotMatch(await readFile(new URL('src/features/system/SystemMessagesView.tsx', webRoot), 'utf8'), /MeetingIM 安全团队/);
|
||||
assert.equal(audioSource, '/message-notification.mp3');
|
||||
assert.equal(playCount, 1);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `node --experimental-strip-types --test tests/brand-and-notification-sound.test.mjs`
|
||||
|
||||
Expected: FAIL,因为品牌仍是 `MeetingIM`,提示音模块和静态资源尚不存在。
|
||||
|
||||
### Task 2: 更新品牌和登录页
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/index.html`
|
||||
- Modify: `web/src/lib/config.ts`
|
||||
- Modify: `web/src/features/auth/LoginPage.tsx`
|
||||
- Modify: `web/src/features/auth/TenantEntryPage.tsx`
|
||||
- Modify: `web/src/features/system/SystemMessagesView.tsx`
|
||||
- Create: `web/public/app-icon.png`
|
||||
- Delete: `web/public/favicon.svg`
|
||||
- Modify: `web/src/lib/notifications/tabIndicator.ts`
|
||||
- Modify: `web/src/lib/notifications/push.ts`
|
||||
|
||||
- [ ] **Step 1: 复制官方品牌图标**
|
||||
|
||||
将 `../im-ios-website/dist/assets/app-icon.png` 原样复制为 `web/public/app-icon.png`,所有 Web 图标引用统一指向它。
|
||||
|
||||
- [ ] **Step 2: 最小化更新可见品牌**
|
||||
|
||||
把浏览器标题、认证页和系统消息署名改为“迅工捷使”;`APP_NAME` 保留 `MeetingIM/<version>` 作为现有 Tinode 客户端技术标识,避免把显示品牌替换扩大成协议改名。
|
||||
|
||||
```ts
|
||||
export const BASE_APP_NAME = '迅工捷使';
|
||||
export const APP_NAME = `MeetingIM/${__APP_VERSION__}`;
|
||||
```
|
||||
|
||||
认证页统一使用:
|
||||
|
||||
```tsx
|
||||
<img src="/app-icon.png" alt="" className="h-full w-full rounded-[inherit]" />
|
||||
<strong>{BASE_APP_NAME}</strong>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 精简登录页**
|
||||
|
||||
说明文案改为“单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。”,删除底部三个入口。
|
||||
|
||||
```tsx
|
||||
<p className="mt-4 max-w-md text-base text-white/80">
|
||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
|
||||
</p>
|
||||
```
|
||||
|
||||
### Task 3: 接入消息提示音
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/lib/notifications/sound.ts`
|
||||
- Modify: `web/src/lib/notifications/push.ts`
|
||||
- Create: `web/public/message-notification.mp3`
|
||||
|
||||
- [ ] **Step 1: 复制用户提供的提示音**
|
||||
|
||||
将 `/Users/aa/Downloads/universfield-new-notification-012-363675.mp3` 原样复制为 `web/public/message-notification.mp3`。
|
||||
|
||||
- [ ] **Step 2: 实现安全播放器**
|
||||
|
||||
首次需要时创建 `Audio('/message-notification.mp3')`,每次播放前设置 `currentTime = 0`;对同步异常和 `play()` Promise 拒绝均静默处理。
|
||||
|
||||
```ts
|
||||
let messageAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function playMessageSound(): void {
|
||||
try {
|
||||
messageAudio ||= new Audio('/message-notification.mp3');
|
||||
messageAudio.currentTime = 0;
|
||||
void messageAudio.play().catch(() => {});
|
||||
} catch {
|
||||
// Optional audio must never interrupt message delivery.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 接入新消息过滤链路**
|
||||
|
||||
把“本人消息、当前可见会话、会话免打扰”抽成通知与声音共享的判断。通过判断后先播放声音;仅当 Notification API 可用且权限为 `granted` 时再显示系统通知。
|
||||
|
||||
```ts
|
||||
if (!shouldAlert(topicName, latest)) return;
|
||||
playMessageSound();
|
||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||
fireNotification(topicName, latest);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 跑绿测**
|
||||
|
||||
Run: `node --experimental-strip-types --test tests/*.test.ts tests/*.test.mjs`
|
||||
|
||||
Expected: 现有 6 项和新增测试全部 PASS。
|
||||
|
||||
### Task 4: 验证、提交与部署
|
||||
|
||||
**Files:**
|
||||
- Verify only
|
||||
|
||||
- [ ] **Step 1: 静态验证**
|
||||
|
||||
Run: `npm run build && npm run lint`
|
||||
|
||||
Expected: 构建退出码 0;lint 无 error,允许记录既有 warning。
|
||||
|
||||
- [ ] **Step 2: 完整性验证**
|
||||
|
||||
Run: `git diff --check`
|
||||
|
||||
Expected: 无输出,退出码 0;MP3 和应用图标的 SHA-256 与来源文件一致。
|
||||
|
||||
- [ ] **Step 3: 提交并推送**
|
||||
|
||||
提交精确修改文件到 `dev`,推送前先 fetch 并确认无远端分叉。
|
||||
|
||||
- [ ] **Step 4: 部署和公网回归**
|
||||
|
||||
触发 Gitea `deploy.yml`,等待成功,确认 K3s Deployment 2/2;访问 `https://web.118im.com` 验证标题、品牌、登录页删减及双账号新消息提醒路径。
|
||||
@@ -0,0 +1,23 @@
|
||||
# Web 聊天图片粘贴兼容设计
|
||||
|
||||
日期:2026-09-29。范围仅限 `web/` 新版聊天输入框的图片粘贴;不改旧版、后端、图片选择器或部署配置。
|
||||
|
||||
## 现状与目标
|
||||
|
||||
当前输入框只读取 `clipboardData.items` 中 `kind=file` 且 `item.type` 为 `image/*` 的首个项目。Windows 微信截图在不同浏览器中的剪贴板暴露形式尚无现场记录,因此不能断言根因;但 `files`、文件本身 MIME、内嵌 HTML 图片和浏览器 Clipboard API 均是当前实现未覆盖的可读路径。线上已经部署了现有粘贴功能。此前“多图暂存并配文字发送”按用户反馈撤回,本次保持**识别到首张图片后立即发送**,不加入预览、多图或图文合并。
|
||||
|
||||
## 方案
|
||||
|
||||
把图片提取逻辑放入一个可独立测试的小模块,输入为粘贴事件中的 `DataTransfer`,输出为首个可发送的 `File` 或“未取得图片”。按顺序尝试:
|
||||
|
||||
1. 同步检查 `items`:对 `kind=file` 调用 `getAsFile()`,以所得文件的 MIME 为准,`item.type` 仅作补充;然后检查 `files`,避免浏览器只填充其中一个集合。
|
||||
2. 同步检查 `text/html` 中的 `<img src="data:image/...">`;只解码本地内嵌图片,不请求远程 URL,不执行 HTML。纯文字和普通 HTML 粘贴保持浏览器原生行为。
|
||||
3. 对没有可用同步图片、也没有纯文字,且事件带图片/文件线索或完全没有数据类型的情况,尝试 `navigator.clipboard.read()` 读取浏览器提供的 `image/*` Blob。仅含普通 HTML 的事件不走此兜底。此路径可能受权限或浏览器支持限制;网页无法越过浏览器直接读取 Windows 原生 CF_DIB 等格式。
|
||||
|
||||
只有确定要接管图片粘贴或进入无文字的异步兜底时,才在事件回调内同步 `preventDefault()`。不得在 `await` 之后访问原粘贴事件的数据。提取成功后仍调用现有 `sendImage`;提取或读取失败时对明确的图片粘贴给出简短提示,发送失败继续沿用现有错误提示。不得让纯文字粘贴触发发送或权限请求。
|
||||
|
||||
## 验收与测试
|
||||
|
||||
先写失败测试,再改实现。覆盖:`items` 的 PNG/JPEG/BMP、`item.type` 缺失但文件 MIME 正确、仅 `files`、`getAsFile()` 为空时回退、HTML 内嵌图片、Clipboard API 返回图片、权限拒绝、纯文字原生粘贴、远程 HTML 图片不抓取、多个图片只取首张。运行定向测试、前端构建和 lint;再用真实浏览器派发图片及文字粘贴事件,核对是否接管和既有即时发送路径。若无 Windows/微信运行环境,最终明确说明这一端到端组合尚未实测,不宣称已覆盖所有微信版本。
|
||||
|
||||
本次只做本地代码与验证;不推送、不部署。
|
||||
@@ -0,0 +1,34 @@
|
||||
# Web 品牌与消息提示音设计
|
||||
|
||||
## 目标
|
||||
|
||||
将当前生产 Web 的用户可见品牌统一为“迅工捷使”,精简登录页指定文案,并使用用户提供的 MP3 为新收到的消息播放提示音。
|
||||
|
||||
## 范围
|
||||
|
||||
- 当前 Dockerfile 实际构建的 `web/` 目录。
|
||||
- 浏览器标题、企业码页、登录页、系统消息署名和应用图标。
|
||||
- 登录页说明改为“消息与回执在手机端保持一致”,删除底部三个入口。
|
||||
- 复用 Android/iOS 已有蓝底扳手图标。
|
||||
- 新消息提示音使用用户提供的 `universfield-new-notification-012-363675.mp3`。
|
||||
|
||||
## 行为
|
||||
|
||||
- 只为他人发来的新消息播放一次提示音。
|
||||
- 正在可见地查看对应会话时不播放。
|
||||
- 会话设置为消息免打扰时不播放。
|
||||
- 声音播放失败不得影响消息接收、列表更新或浏览器通知。
|
||||
- 浏览器系统通知权限不控制声音;声音与现有通知共用同一条新消息判定链路。
|
||||
|
||||
## 明确不改
|
||||
|
||||
- 后端代码和后端默认推送、短信名称。
|
||||
- `X-MeetingIM-APIKey`、`X-MeetingIM-Auth` 等协议头。
|
||||
- `meetingim-session` 等已有存储键。
|
||||
- 仓库名、镜像名、域名和旧版未部署 Web 入口。
|
||||
|
||||
## 验证
|
||||
|
||||
- 自动测试覆盖品牌文字、登录页删减、品牌图片引用和提示音播放器。
|
||||
- Web 全量测试、构建、lint 和 `git diff --check`。
|
||||
- 部署后实测企业码页、登录页、浏览器标题、图标,以及双账号新消息提示音路径。
|
||||
@@ -0,0 +1,59 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
WORKFLOW = Path(__file__).resolve().parents[1] / ".gitea/workflows/deploy.yml"
|
||||
DOCKERFILE = Path(__file__).resolve().parents[1] / "Dockerfile"
|
||||
API_CLIENT = Path(__file__).resolve().parents[1] / "web/src/lib/api/client.ts"
|
||||
|
||||
|
||||
class DeployWorkflowContractTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
cls.dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||
cls.api_client = API_CLIENT.read_text(encoding="utf-8")
|
||||
|
||||
def test_targets_pro_branch_and_single_runner(self):
|
||||
self.assertIn("push:", self.workflow)
|
||||
self.assertIn("- pro", self.workflow)
|
||||
self.assertIn("workflow_dispatch:", self.workflow)
|
||||
self.assertIn("runs-on: deploy-host", self.workflow)
|
||||
self.assertIn("concurrency:", self.workflow)
|
||||
|
||||
def test_builds_and_pushes_pro_image(self):
|
||||
self.assertIn("git.olns.shop/pro/im_webapp", self.workflow)
|
||||
self.assertIn("docker push", self.workflow)
|
||||
self.assertIn(":pro", self.workflow)
|
||||
|
||||
def test_deploys_compose_service_with_pinned_host_key(self):
|
||||
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
|
||||
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
|
||||
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
|
||||
self.assertIn("docker compose pull im-webapp", self.workflow)
|
||||
self.assertIn("docker compose up -d --no-deps --wait im-webapp", self.workflow)
|
||||
|
||||
def test_removes_old_kubernetes_and_registry_targets(self):
|
||||
self.assertNotIn("kubectl", self.workflow)
|
||||
self.assertNotIn("KUBECONFIG", self.workflow)
|
||||
self.assertNotIn("git.118im.com", self.workflow)
|
||||
|
||||
def test_uses_production_im_endpoint(self):
|
||||
self.assertIn('TINODE_API_BASE="https://im-api.yuexuan.shop/v1/im"', self.workflow)
|
||||
self.assertIn('TINODE_FILE_BASE="https://im-api.yuexuan.shop/v0/file"', self.workflow)
|
||||
|
||||
def test_build_exposes_configured_api_base_to_vite(self):
|
||||
self.assertIn("ARG TINODE_API_BASE=", self.dockerfile)
|
||||
self.assertIn("ENV VITE_TINODE_API_BASE=$TINODE_API_BASE", self.dockerfile)
|
||||
|
||||
def test_strips_accidentally_quoted_api_key_before_build(self):
|
||||
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY#\\\'}"', self.workflow)
|
||||
self.assertIn('TINODE_API_KEY="${TINODE_API_KEY%\\\'}"', self.workflow)
|
||||
|
||||
def test_api_client_prefers_the_configured_production_origin(self):
|
||||
self.assertIn("import.meta.env.VITE_TINODE_API_BASE?.trim()", self.api_client)
|
||||
self.assertIn("CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE", self.api_client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/app-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
|
||||
<title>迅工捷使</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2944
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test": "node --test tests/*.test.mjs tests/*.test.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"agora-rtc-sdk-ng": "^4.24.8",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"tinode-sdk": "^0.25.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"oxlint": "^1.79.0",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
Binary file not shown.
+141
@@ -0,0 +1,141 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
|
||||
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
import { RegisterPage } from '@/features/auth/RegisterPage';
|
||||
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
|
||||
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
|
||||
import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
|
||||
import { CallHistoryView } from '@/features/calls/CallHistoryView';
|
||||
import { BlocklistView } from '@/features/contacts/BlocklistView';
|
||||
import { ContactsLayout } from '@/features/contacts/ContactsLayout';
|
||||
import { EmptyContactsMain } from '@/features/contacts/EmptyContactsMain';
|
||||
import { FriendProfileView } from '@/features/contacts/FriendProfileView';
|
||||
import { GroupListView } from '@/features/contacts/GroupListView';
|
||||
import { NewFriendsView } from '@/features/contacts/NewFriendsView';
|
||||
import { FavoritesLayout } from '@/features/favorites/FavoritesLayout';
|
||||
import { ConversationView } from '@/features/messages/ConversationView';
|
||||
import { EmptyMain } from '@/features/messages/EmptyMain';
|
||||
import { MessagesLayout } from '@/features/messages/MessagesLayout';
|
||||
import { GeneralSettingsPage } from '@/features/settings/GeneralSettingsPage';
|
||||
import { NotificationsSettingsPage } from '@/features/settings/NotificationsSettingsPage';
|
||||
import { PrivacySettingsPage } from '@/features/settings/PrivacySettingsPage';
|
||||
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
|
||||
import { SecuritySettingsPage } from '@/features/settings/SecuritySettingsPage';
|
||||
import { SettingsHome } from '@/features/settings/SettingsHome';
|
||||
import { SettingsLayout } from '@/features/settings/SettingsLayout';
|
||||
import { SystemMessagesView } from '@/features/system/SystemMessagesView';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/**
|
||||
* Without this, an uncaught render-time exception ANYWHERE under `<AppShell>` (any page, any one
|
||||
* bad field in server data) falls through to react-router's own built-in default error boundary --
|
||||
* real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the
|
||||
* outermost matched route the way this codebase never configured one to). That default shows
|
||||
* "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the
|
||||
* WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to
|
||||
* AppShell (not each leaf route) so it covers every authenticated page with one component, and low
|
||||
* enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏.
|
||||
*/
|
||||
function RouteErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (import.meta.env.DEV) console.error(error);
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-surface-page px-6 text-center">
|
||||
<Icon name="error_outline" className="text-[40px] text-text-tertiary-3" />
|
||||
<p className="text-sm text-text-secondary">页面出现异常,请刷新重试</p>
|
||||
<button type="button" onClick={() => window.location.reload()} className="rounded-lg bg-accent px-4 py-2 text-sm text-white">
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'register', element: <RegisterPage /> },
|
||||
{ path: 'reset-password', element: <ResetPasswordPage /> },
|
||||
{ path: 'enterprise-register', element: <EnterpriseRegisterPage /> },
|
||||
{ path: 'feedback', element: <TenantFeedbackPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
errorElement: <RouteErrorBoundary />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/messages" replace /> },
|
||||
{
|
||||
path: 'messages',
|
||||
element: <MessagesLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyMain /> },
|
||||
{ path: 'system', element: <SystemMessagesView /> },
|
||||
{ path: ':topicId', element: <ConversationView /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'contacts',
|
||||
element: <ContactsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <EmptyContactsMain /> },
|
||||
{ path: 'requests', element: <NewFriendsView /> },
|
||||
{ path: 'groups', element: <GroupListView /> },
|
||||
{ path: 'blocklist', element: <BlocklistView /> },
|
||||
{ path: ':userId', element: <FriendProfileView /> },
|
||||
],
|
||||
},
|
||||
{ path: 'favorites', element: <FavoritesLayout /> },
|
||||
// README §9.2's dedicated file-assistant browser (categories, storage quota, upload
|
||||
// progress) has NO backing REST API at all (confirmed via a research pass -- the design
|
||||
// spec's own state-management notes admit reuse of existing APIs, i.e. reading the `slf`
|
||||
// topic's messages, same as any other conversation) -- redirecting into the real
|
||||
// conversation on that topic is honest; a fake quota card with invented numbers isn't.
|
||||
{ path: 'files', element: <Navigate to="/messages/slf" replace /> },
|
||||
{ path: 'calls', element: <CallHistoryView /> },
|
||||
{
|
||||
path: 'settings',
|
||||
element: <SettingsLayout />,
|
||||
children: [
|
||||
{ index: true, element: <SettingsHome /> },
|
||||
{ path: 'profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'security', element: <SecuritySettingsPage /> },
|
||||
{ path: 'notifications', element: <NotificationsSettingsPage /> },
|
||||
{ path: 'privacy', element: <PrivacySettingsPage /> },
|
||||
{ path: 'general', element: <GeneralSettingsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// A stale bookmark/link to a route renamed during this redesign should land somewhere the app
|
||||
// itself resolves (tenant/login/messages, per RequireAuth's own gating), not React Router's
|
||||
// generic unstyled default error boundary.
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
{/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert()
|
||||
replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch
|
||||
tenant" confirmation), not just the logged-in app tree. */}
|
||||
<ConfirmDialogRoot />
|
||||
<GlobalToastRoot />
|
||||
<KickedNoticeRoot />
|
||||
<SecurityChallengeModal />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { UserCardRoot } from '@/app/UserCardRoot';
|
||||
import { CallRoot } from '@/features/calls/CallRoot';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { subscribeTopicInfo } from '@/lib/tinode/topics';
|
||||
import { initCallSignaling } from '@/stores/calls';
|
||||
import { useNotificationNavStore } from '@/stores/notificationNav';
|
||||
|
||||
import { BottomTabBar } from './BottomTabBar';
|
||||
import { NavRail } from './NavRail';
|
||||
|
||||
/**
|
||||
* Top-level chrome: persistent icon rail (>=640px) or bottom tab bar (<640px), wrapping whichever
|
||||
* feature section is active. Each feature section owns its own list/main/detail responsive split
|
||||
* internally (see e.g. features/messages) -- this shell only decides "rail vs tabs", nothing about
|
||||
* columns within a section.
|
||||
*/
|
||||
export function AppShell() {
|
||||
const navigate = useNavigate();
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts
|
||||
// first fetches it and the other just reads the cache, not two redundant requests. This was
|
||||
// never wired up here before, so the rail's self-tile (NavRail below) had no avatar/name data
|
||||
// at all and fell back to a permanently-hardcoded "我" glyph regardless of who was logged in or
|
||||
// what avatar they'd uploaded -- see Avatar.tsx's own comment for the wider version of this gap.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
// Once per app session, regardless of which page is open -- an incoming call must surface even
|
||||
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
|
||||
useEffect(() => {
|
||||
initCallSignaling();
|
||||
}, []);
|
||||
const queryClient = useQueryClient();
|
||||
// Friend-request realtime signals (Zentao #3): after a request is created/accepted/rejected the
|
||||
// server pushes `{info what:"im" event:"friend.request.*"}` to the recipient's `me` topic
|
||||
// (publishIMRelationEvent -> sessionStore.DeliverToUser). Nothing here used to listen for it --
|
||||
// the SDK's _routeInfo hits its default case for what:"im" and only fires the onInfo chain -- so
|
||||
// the 新朋友 badge/list stayed stale until a manual refresh or relogin. Subscribe once per app
|
||||
// session (same shape as initCallSignaling above) and invalidate the shared ['friend-requests']
|
||||
// query namespace: ContactsListColumn's pending badge (['friend-requests','inbound','pending'])
|
||||
// and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening
|
||||
// here rather than in those views keeps the badge live no matter which page the user is on.
|
||||
useEffect(() => {
|
||||
return subscribeTopicInfo((topicName, info) => {
|
||||
if (info?.event?.startsWith('friend.request.')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
}
|
||||
if (info?.event === 'group.member.profile.updated' && topicName.startsWith('grp')) {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicName] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicName] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicName] });
|
||||
}
|
||||
});
|
||||
}, [queryClient]);
|
||||
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
|
||||
// in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
|
||||
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
|
||||
// 文件助手 (which used to own its own entry_badges count, file_assistant_unread_count) has been
|
||||
// removed as a UI entry point entirely -- see ChatListColumn.tsx's PINNED_ENTRIES doc comment --
|
||||
// so "slf" is now just an ordinary contact row like any other, and should count normally here too.
|
||||
const unreadMessages =
|
||||
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
|
||||
(bootstrap.data?.entry_badges?.system_unread_count || 0);
|
||||
|
||||
// Same canonical number the rail/tab-bar badges above already show -- keeps the browser tab's
|
||||
// favicon badge and (while backgrounded) scrolling title in sync with it rather than tracking a
|
||||
// second, potentially-divergent count.
|
||||
useEffect(() => {
|
||||
updateTabIndicator(unreadMessages);
|
||||
}, [unreadMessages]);
|
||||
|
||||
// Consumes a desktop-notification click (lib/notifications/push.ts has no React Router access of
|
||||
// its own -- see stores/notificationNav.ts for why this goes through a store instead).
|
||||
const pendingNotificationTopic = useNotificationNavStore((s) => s.pendingTopic);
|
||||
useEffect(() => {
|
||||
if (!pendingNotificationTopic) return;
|
||||
navigate(`/messages/${pendingNotificationTopic}`);
|
||||
useNotificationNavStore.getState().clear();
|
||||
}, [pendingNotificationTopic, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
|
||||
<div className="hidden sm:flex sm:h-full">
|
||||
<NavRail
|
||||
unreadMessages={unreadMessages}
|
||||
selfId={meQuery.data?.user_id}
|
||||
selfName={meQuery.data?.profile.nickname || '我'}
|
||||
selfAvatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
<div className="sm:hidden">
|
||||
<BottomTabBar unreadMessages={unreadMessages} />
|
||||
</div>
|
||||
</div>
|
||||
<CallRoot />
|
||||
<UserCardRoot />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
|
||||
interface AvatarProps {
|
||||
/** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */
|
||||
id: string;
|
||||
name: string | undefined;
|
||||
avatarFileId?: string;
|
||||
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
|
||||
* own color/centering/weight are applied internally, not expected from the caller. */
|
||||
className?: string;
|
||||
/** Overrides the hashed `avatarBgClass(id)` fallback color -- e.g. "bg-accent" for the current
|
||||
* user's own message-row avatar, which by design is always the fixed accent color rather than a
|
||||
* per-id hash like every other tile. No effect once avatarFileId resolves to a real image. */
|
||||
fallbackBgClass?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped
|
||||
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
|
||||
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
|
||||
* avatar produced zero visible change anywhere, including on the very page the upload happened.
|
||||
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
|
||||
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
|
||||
* attachments) reconstructed -- but live-confirmed (2026-09-18): default-avatar placeholders come
|
||||
* back as an ALREADY-COMPLETE OSS URL in this same field (e.g.
|
||||
* "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/im-assets/default-avatars/male/4.png"). A first
|
||||
* fix attempt stopped re-wrapping that URL into the malformed "/v0/file/s/https://..." shape but
|
||||
* still routed it through `useAuthedBlobUrl`'s authenticated fetch, which then failed when tested
|
||||
* from a local dev origin -- turned out to be a false lead: the OSS bucket's CORS rule (checked
|
||||
* directly in the Aliyun console) DOES allow this app's auth headers, just scoped to the real
|
||||
* `web/web1-5.118im.com` origins, which a local dev server was never going to match regardless of
|
||||
* bucket config (same class of mistake this codebase has hit before testing OSS URLs from
|
||||
* localhost). The actual reason to skip `useAuthedBlobUrl` for an absolute URL isn't "CORS is
|
||||
* broken" -- it's that the auth headers it attaches are meaningless to OSS for a public asset in the
|
||||
* first place, so the fetch-to-blob round trip (plus its preflight) is pure overhead; render the URL
|
||||
* directly as a plain `<img src>` instead, same as any other publicly-loadable image.
|
||||
*/
|
||||
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
|
||||
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
|
||||
const remote = useAuthedBlobUrl(avatarFileId && !isAbsoluteUrl ? `/v0/file/s/${avatarFileId}` : undefined);
|
||||
const resolvedUrl = isAbsoluteUrl ? avatarFileId : remote.url;
|
||||
if (resolvedUrl) {
|
||||
return <img src={resolvedUrl} alt="" className={`object-cover ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface TabItem {
|
||||
to: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the old app's message-center-view.jsx-embedded 2-tab (消息/工作台) bar: this one is
|
||||
* top-level (visible regardless of which section is open, not just inside the message list) and
|
||||
* has 3 tabs per the new design, since 工作台 no longer exists.
|
||||
*/
|
||||
export function BottomTabBar({ unreadMessages = 0 }: { unreadMessages?: number }) {
|
||||
const tabs: TabItem[] = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息', badge: unreadMessages },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/settings', icon: 'person', label: '我的' },
|
||||
];
|
||||
return (
|
||||
<nav className="grid h-[82px] flex-none grid-cols-3 border-t border-border-panel bg-white pb-4.5">
|
||||
{tabs.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
`relative flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
|
||||
isActive ? 'text-accent' : 'text-text-tertiary-3'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon name={tab.icon} className="!text-2xl" />
|
||||
{tab.badge ? (
|
||||
<b className="absolute -right-2 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
|
||||
{tab.badge}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useConfirmDialogStore } from '@/stores/confirmDialog';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts)
|
||||
* most recently asked for. Visual language matches this app's other small action dialogs (e.g. the
|
||||
* forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own
|
||||
* spec for that pairing) rather than inventing a new button treatment just for this. */
|
||||
export function ConfirmDialogRoot() {
|
||||
const pending = useConfirmDialogStore((s) => s.pending);
|
||||
const respond = useConfirmDialogStore((s) => s.respond);
|
||||
// Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is
|
||||
// harmless, matching how every other modal in this app registers unconditionally too.
|
||||
useEscapeKey(() => respond(false));
|
||||
|
||||
if (!pending) return null;
|
||||
const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(false)}>
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{title ? <strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong> : null}
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(false)}
|
||||
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(true)}
|
||||
className={`grid h-9 place-items-center rounded-lg px-4 text-sm font-medium text-white ${danger ? 'bg-danger-text' : 'bg-btn'}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { useGlobalToastStore } from '@/stores/globalToast';
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most
|
||||
* recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss
|
||||
* timer restarts even when the exact same message is fired twice in a row, which a plain `message`
|
||||
* string prop wouldn't detect as a change. */
|
||||
export function GlobalToastRoot() {
|
||||
const message = useGlobalToastStore((s) => s.message);
|
||||
const variant = useGlobalToastStore((s) => s.variant);
|
||||
const token = useGlobalToastStore((s) => s.token);
|
||||
if (!message) return null;
|
||||
return <Toast key={token} message={message} variant={variant} onDone={() => useGlobalToastStore.setState({ message: '' })} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
|
||||
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
|
||||
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
|
||||
* interactive element (button/link with no visible text) must set its own `aria-label`.
|
||||
*
|
||||
* Deliberately `im-icon`, NOT Google Fonts' own `.material-icons` class -- see index.css's own
|
||||
* comment on that rule for why: Google's stylesheet ships that class completely unlayered, which
|
||||
* under CSS Cascade Layers always outranks Tailwind's layered utilities regardless of specificity
|
||||
* or source order, silently ignoring every `text-[Npx]` size override across the whole app. */
|
||||
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<i aria-hidden="true" className={`im-icon select-none ${className}`} style={style}>
|
||||
{name}
|
||||
</i>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { clearKickedInfo } from '@/lib/tinode/connection';
|
||||
import { kickedNoticeText } from '@/lib/tinode/errors';
|
||||
import { useKickedInfo } from '@/lib/tinode/hooks';
|
||||
|
||||
/**
|
||||
* Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot --
|
||||
* an eviction can land on any authenticated page, not just ones that happen to render their own
|
||||
* connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already
|
||||
* torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's
|
||||
* only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端
|
||||
* 接口文档.md §2.4's "弹出中文提示后跳转登录页".
|
||||
*
|
||||
* A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for
|
||||
* an eviction that already happened, offering one would be misleading. Hard `window.location.href`
|
||||
* navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical
|
||||
* reason: every piece of in-memory state (TanStack Query cache, any component's local state) should
|
||||
* reset from scratch, not rely on each one separately noticing "you're logged out now".
|
||||
*/
|
||||
export function KickedNoticeRoot() {
|
||||
const kicked = useKickedInfo();
|
||||
if (!kicked) return null;
|
||||
const { title, message } = kickedNoticeText(kicked.reason);
|
||||
|
||||
const acknowledge = () => {
|
||||
clearKickedInfo();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
|
||||
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from './Avatar';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
interface NavRailItem {
|
||||
to: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
const TOP_ITEMS: NavRailItem[] = [
|
||||
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
|
||||
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
|
||||
{ to: '/favorites', icon: 'inventory_2', label: '收藏' },
|
||||
];
|
||||
|
||||
const BOTTOM_ITEMS: NavRailItem[] = [
|
||||
{ to: '/calls', icon: 'phone', label: '通话记录' },
|
||||
{ to: '/settings', icon: 'settings', label: '设置' },
|
||||
];
|
||||
|
||||
function isNavItemActive(to: string, pathname: string): boolean {
|
||||
if (to === '/messages') return pathname.startsWith('/messages');
|
||||
return pathname === to || pathname.startsWith(`${to}/`);
|
||||
}
|
||||
|
||||
function NavRailButton({ item }: { item: NavRailItem }) {
|
||||
const location = useLocation();
|
||||
const isActive = isNavItemActive(item.to, location.pathname);
|
||||
return (
|
||||
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
|
||||
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
|
||||
// prop only changes the STRING used, not whether it's applied at all, which stays gated on
|
||||
// NavLink's internal isActive), so a second, independent aria-current computation could in
|
||||
// principle disagree with the `className` highlight computed by `isActive` below. Driving both
|
||||
// off the one shared `isActive` value keeps them impossible to desync.
|
||||
<Link
|
||||
to={item.to}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
|
||||
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="!text-2xl" />
|
||||
{item.badge ? (
|
||||
<b className="absolute top-1 right-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
|
||||
{item.badge}
|
||||
</b>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavRailProps {
|
||||
unreadMessages?: number;
|
||||
/** The logged-in user's own id/name/avatar (`GET /v1/im/me`) -- drives the rail's top self-tile.
|
||||
* All optional so the rail still renders (as a letter-tile fallback) before that request settles. */
|
||||
selfId?: string;
|
||||
selfName?: string;
|
||||
selfAvatarFileId?: string;
|
||||
}
|
||||
|
||||
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
||||
export function NavRail({ unreadMessages = 0, selfId, selfName, selfAvatarFileId }: NavRailProps) {
|
||||
return (
|
||||
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
|
||||
<Link to="/settings/profile" title="个人资料" aria-label="个人资料" className="mb-[22px]">
|
||||
<Avatar id={selfId || ''} name={selfName} avatarFileId={selfAvatarFileId} className="h-[38px] w-[38px] rounded-[10px] text-base" />
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col items-center gap-1.5">
|
||||
{TOP_ITEMS.map((item) => (
|
||||
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
{BOTTOM_ITEMS.map((item) => (
|
||||
<NavRailButton key={item.to} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { initMessageNotifications } from '@/lib/notifications/push';
|
||||
import { restoreSession } from '@/lib/tinode/connection';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
|
||||
* socket's own onopen/onerror) -- past this, assume the host is unreachable in a way that won't
|
||||
* self-resolve (stalled handshake, silently-dropped middlebox) rather than hang the restore screen
|
||||
* forever with no escape route. */
|
||||
const RESTORE_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Gate for everything inside AppShell: needs a resolved tenant and an authenticated user.
|
||||
*
|
||||
* `tenant` survives a same-tab reload (persisted, see stores/session.ts) but `myUserId` doesn't --
|
||||
* it used to always come back `null` on reload, bouncing straight to /login even though every
|
||||
* login/register already writes a perfectly good reusable token to `sessionStorage['im-session-
|
||||
* token']` for exactly this case. This re-derives `myUserId` from that token via connection.ts's
|
||||
* restoreSession() (deduped against React 19 StrictMode's double-invoke in dev -- see its own
|
||||
* comment) before giving up and redirecting, instead of ignoring the token entirely.
|
||||
*/
|
||||
export function RequireAuth() {
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const clearTenant = useSessionStore((s) => s.clearTenant);
|
||||
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
|
||||
const [restoreFailed, setRestoreFailed] = useState(false);
|
||||
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
|
||||
const [contactsError, setContactsError] = useState('');
|
||||
const meSubscribedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (myUserId || !tenant || !hasToken) return undefined;
|
||||
const token = sessionStorage.getItem('im-session-token');
|
||||
if (!token) return undefined;
|
||||
let cancelled = false;
|
||||
const timeout = window.setTimeout(() => {
|
||||
if (!cancelled) setRestoreTimedOut(true);
|
||||
}, RESTORE_TIMEOUT_MS);
|
||||
restoreSession(token)
|
||||
.then((uid) => {
|
||||
if (cancelled) return;
|
||||
setMyUserId(uid);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
// docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently
|
||||
// resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回
|
||||
// 企业码页" -- clearing the tenant here lets the fallback `<Navigate to="/login"/>` below
|
||||
// do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to
|
||||
// `/tenant`, rather than adding a second, parallel navigation path.
|
||||
if (isTenantMismatch(err)) clearTenant();
|
||||
setRestoreFailed(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
window.clearTimeout(timeout);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
|
||||
|
||||
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
|
||||
// of HOW it became known (session restore above, or a fresh login that already called
|
||||
// setMyUserId before this component ever mounted) -- LoginPage used to fire its own
|
||||
// subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly logged-in user
|
||||
// with an empty conversation list and no error on a transient failure. Centralizing here means it
|
||||
// happens exactly once per authenticated mount and surfaces the same Toast either way.
|
||||
useEffect(() => {
|
||||
if (!myUserId || meSubscribedRef.current) return;
|
||||
meSubscribedRef.current = true;
|
||||
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
|
||||
}, [myUserId]);
|
||||
|
||||
// Separate from the one-time subscribeMeTopic effect above on purpose: this one has real
|
||||
// start/stop semantics (a timer + listeners, not a non-idempotent WS packet) and needs to
|
||||
// restart cleanly across StrictMode's mount->cleanup->remount, not be guarded against it. See
|
||||
// startMeReconciliation's own comment for what backend gap this is mitigating.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return undefined;
|
||||
return startMeReconciliation();
|
||||
}, [myUserId]);
|
||||
|
||||
// Same start/stop shape as the reconciliation effect above, same reason it isn't merged into the
|
||||
// one-time subscribeMeTopic effect.
|
||||
useEffect(() => {
|
||||
if (!myUserId) return undefined;
|
||||
return initMessageNotifications();
|
||||
}, [myUserId]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
if (myUserId) {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<Toast message={contactsError} variant="error" onDone={() => setContactsError('')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
// `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here
|
||||
// (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in
|
||||
// the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global
|
||||
// 401 handler both clear it without this component's own effect ever running). Without this,
|
||||
// `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever --
|
||||
// there's no token left to restore and nothing would ever resolve that screen.
|
||||
if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) {
|
||||
// restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so
|
||||
// would unmount this component, which sets the effect's `cancelled` flag and makes the still-
|
||||
// in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A
|
||||
// merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would
|
||||
// otherwise get bounced to a forced re-login even when the restore was about to succeed a moment
|
||||
// later. Only an actual rejection (restoreFailed) or the user's own click below should leave.
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
|
||||
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
|
||||
{/* Only past the same timeout the message text itself waits for -- a normal restore usually
|
||||
resolves in well under a second (real WS round trip), and this link showing unconditionally
|
||||
from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape
|
||||
hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为
|
||||
重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told
|
||||
the user this is taking longer than normal. */}
|
||||
{restoreTimedOut ? (
|
||||
<a href="/login" className="text-accent no-underline">
|
||||
改为重新登录
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
|
||||
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
|
||||
|
||||
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
|
||||
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
|
||||
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
|
||||
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
|
||||
* and there was previously no UI anywhere in this app that could produce one. */
|
||||
export function SecurityChallengeModal() {
|
||||
const pending = useSecurityChallengeStore((s) => s.pending);
|
||||
const respond = useSecurityChallengeStore((s) => s.respond);
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [phoneMask, setPhoneMask] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
useEscapeKey(() => respond(null));
|
||||
|
||||
const tick = (initial: number) => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setSeconds(initial);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
|
||||
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
|
||||
// the phone number isn't something the user enters here at all (read server-side from the login
|
||||
// identity), so there's nothing to wait on the user to fill in first.
|
||||
useEffect(() => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setChallengeId('');
|
||||
setCode('');
|
||||
setError('');
|
||||
setSeconds(0);
|
||||
if (!pending) return undefined;
|
||||
let cancelled = false;
|
||||
requestSecurityChallenge(pending.scene)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setChallengeId(data.challenge_id);
|
||||
setPhoneMask(data.phone_mask);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pending?.scene]);
|
||||
|
||||
if (!pending) return null;
|
||||
|
||||
const resend = () => {
|
||||
if (busy || seconds > 0) return;
|
||||
setError('');
|
||||
setBusy(true);
|
||||
requestSecurityChallenge(pending.scene)
|
||||
.then((data) => {
|
||||
setChallengeId(data.challenge_id);
|
||||
setPhoneMask(data.phone_mask);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
})
|
||||
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const verified = await verifySecurityChallenge(challengeId, code);
|
||||
respond(verified.action_token);
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
|
||||
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
|
||||
<p className="m-0 text-sm leading-relaxed text-text-body">
|
||||
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
|
||||
</p>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="请输入 6 位验证码"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={busy || seconds > 0 || !challengeId}
|
||||
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => respond(null)}
|
||||
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={busy || !challengeId || code.length !== 6}
|
||||
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? '验证中…' : '确定'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
interface SelectionCheckboxProps {
|
||||
selected: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared circular multi-select indicator -- every list in this app that lets you pick multiple
|
||||
* items (forward targets, message multi-select, create-group contact picker, favorites, blocklist)
|
||||
* used to hand-copy this same circle-that-fills-with-a-checkmark markup into its own file, each
|
||||
* with small, accidental class differences (a stray extra border in the selected state here, a
|
||||
* slightly different gray there) -- fixing the same visual bug five separate times is exactly the
|
||||
* failure mode a shared component exists to prevent.
|
||||
*
|
||||
* Draws the checkmark as a hand-drawn SVG path, not the Material Icons font glyph `Icon` uses
|
||||
* everywhere else: the font's hinting isn't tuned for a ~13px glyph inside a 20px circle, so above
|
||||
* 1x pixel density it renders visibly soft/asymmetric where a vector path stays crisp at any scale.
|
||||
* A deliberate one-off exception to this app's usual `Icon`-for-everything convention, not a second
|
||||
* icon system -- scoped to this one selection indicator.
|
||||
*/
|
||||
export function SelectionCheckbox({ selected, className = '' }: SelectionCheckboxProps) {
|
||||
return (
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'} ${className}`}
|
||||
>
|
||||
{selected ? (
|
||||
<svg viewBox="0 0 16 16" fill="none" className="h-3 w-3 text-white" aria-hidden="true">
|
||||
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { TOAST_DURATION } from '@/lib/config';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
variant?: 'success' | 'error';
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function Toast({ message, variant = 'success', onDone }: ToastProps) {
|
||||
// Call sites pass an inline `onDone` arrow, so a plain `[message, onDone]` dependency array
|
||||
// restarts the auto-dismiss timer on every unrelated parent re-render (e.g. typing in an
|
||||
// unrelated field after the toast appears) instead of only when a NEW message is shown.
|
||||
const onDoneRef = useRef(onDone);
|
||||
useEffect(() => {
|
||||
onDoneRef.current = onDone;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) return undefined;
|
||||
const timer = window.setTimeout(() => onDoneRef.current(), TOAST_DURATION);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [message]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
// `position:fixed` resolves against the viewport, not AppShell's own Outlet box -- below 640px
|
||||
// that viewport bottom is where the persistent BottomTabBar (82px tall) already sits, so the
|
||||
// unconditional `bottom-12` this used to be sat squarely on top of it on every phone-width
|
||||
// screen, for every Toast call site in the app. `sm:bottom-12` restores the original 48px gap
|
||||
// once the tab bar is gone (>=640px, NavRail shown instead).
|
||||
className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12"
|
||||
>
|
||||
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getUserProfile } from '@/lib/api/friends';
|
||||
import { getMyProfile } from '@/lib/api/profile';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { closeUserCard, useUserCardStore } from '@/stores/userCard';
|
||||
|
||||
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
|
||||
|
||||
/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts)
|
||||
* most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/
|
||||
* :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a
|
||||
* replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */
|
||||
export function UserCardRoot() {
|
||||
const userId = useUserCardStore((s) => s.userId);
|
||||
if (!userId) return null;
|
||||
// key={userId} so switching straight from one card to another (tapping a different @mention
|
||||
// before closing the first) resets the query/local state instead of showing stale data mid-fetch.
|
||||
return <UserCardPanel key={userId} userId={userId} />;
|
||||
}
|
||||
|
||||
function UserCardPanel({ userId }: { userId: string }) {
|
||||
const navigate = useNavigate();
|
||||
// Registered here, not in the always-mounted UserCardRoot above: useEscapeKey's stack (lib/hooks.ts)
|
||||
// orders entries by MOUNT time, so an entry registered once at app boot would sit permanently at
|
||||
// the bottom, below every modal that mounts later -- a real, reproduced bug (open GroupMembersModal
|
||||
// -> tap a member row to open this card on top of it -> Escape closed the member-list modal
|
||||
// underneath instead of this card). UserCardPanel only mounts while a card is genuinely open, same
|
||||
// as every other overlay's own useEscapeKey call, so this now stacks correctly.
|
||||
useEscapeKey(closeUserCard);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const isSelf = !!myUserId && myUserId === userId;
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
|
||||
// Real, live-confirmed bug (2026-09-25 review): `GET /users/{id}/profile` 400s on a SELF lookup,
|
||||
// same self-lookup gap already documented and fixed elsewhere for the group-scoped equivalent
|
||||
// (getMyGroupProfile's own comment, `lib/api/groups.ts`) -- tapping your OWN avatar (reachable from
|
||||
// the member grid/list like anyone else's) always landed on the "加载失败,请重试" branch, retrying
|
||||
// into the same 400 forever. Routes self through the dedicated `GET /me/profile` instead, which has
|
||||
// no such restriction; normalized into the same shape the render below already expects so the two
|
||||
// branches don't need two separate JSX trees.
|
||||
const otherQuery = useQuery({
|
||||
queryKey: ['user-profile', userId],
|
||||
queryFn: () => getUserProfile(userId),
|
||||
enabled: !isSelf,
|
||||
});
|
||||
const selfQuery = useQuery({
|
||||
queryKey: ['my-profile-card', userId],
|
||||
queryFn: () => getMyProfile(),
|
||||
enabled: isSelf,
|
||||
});
|
||||
const query = isSelf ? selfQuery : otherQuery;
|
||||
const profile = isSelf
|
||||
? selfQuery.data
|
||||
? {
|
||||
user_id: userId,
|
||||
display_name: selfQuery.data.nickname,
|
||||
avatar_file_id: selfQuery.data.avatar_file_id,
|
||||
gender: selfQuery.data.gender,
|
||||
region: selfQuery.data.region,
|
||||
signature: selfQuery.data.signature,
|
||||
public_account: selfQuery.data.public_account,
|
||||
relationship: 'self',
|
||||
remark: undefined as string | undefined,
|
||||
capabilities: undefined as { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean } | undefined,
|
||||
}
|
||||
: undefined
|
||||
: otherQuery.data;
|
||||
const name = profile ? profile.remark || profile.display_name : '';
|
||||
// Real bug this fixes: this card's own doc comment always claimed "same relationship-aware action
|
||||
// gating" as FriendProfileView.tsx, but until now it never read `profile.relationship` at all --
|
||||
// tapping a non-friend's avatar (this card is reachable from many more places than the friends-only
|
||||
// surfaces FriendProfileView is, e.g. any group member's avatar or an @mention) showed an enabled
|
||||
// "发消息"/call row with no add-friend affordance anywhere, gated only by `capabilities.can_send`/
|
||||
// `can_call` -- a signal ConversationView.tsx's own comment already documents as unreliable for
|
||||
// exactly this purpose (it 403s from a blocked party's own side instead of reporting false). Mirrors
|
||||
// FriendProfileView's own isFriend/isPendingOutbound/isPendingInbound branching exactly, just at
|
||||
// this card's single (not two-size-variant) button size.
|
||||
const isFriend = profile?.relationship === 'friend';
|
||||
const isPendingOutbound = profile?.relationship === 'pending_outbound';
|
||||
const isPendingInbound = profile?.relationship === 'pending_inbound';
|
||||
|
||||
const goToFullProfile = () => {
|
||||
closeUserCard();
|
||||
navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
|
||||
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeUserCard}
|
||||
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
|
||||
>
|
||||
<Icon name="close" className="text-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !profile ? (
|
||||
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
|
||||
{query.error instanceof ImApiError && query.error.status === 403 ? (
|
||||
'对方设置了资料保护,暂时无法查看'
|
||||
) : query.error instanceof ImApiError && query.error.status === 404 ? (
|
||||
'未找到该用户'
|
||||
) : (
|
||||
<>
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center px-6 pb-6">
|
||||
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
|
||||
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
|
||||
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
|
||||
{profile.gender || profile.region ? (
|
||||
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
|
||||
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
|
||||
|
||||
{!isSelf ? (
|
||||
<div className="mt-5 flex w-full items-center gap-2">
|
||||
{isFriend ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_send === false}
|
||||
onClick={() => {
|
||||
closeUserCard();
|
||||
navigate(`/messages/${profile.user_id}`);
|
||||
}}
|
||||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
|
||||
>
|
||||
<Icon name="chat_bubble" className="text-base" />
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'audio');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="call" className="text-base" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase !== 'idle') return;
|
||||
closeUserCard();
|
||||
useCallStore.getState().startCall(profile.user_id, name, 'video');
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="videocam" className="text-base" />
|
||||
</button>
|
||||
</>
|
||||
) : isPendingOutbound ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
等待验证
|
||||
</button>
|
||||
) : isPendingInbound ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
对方已申请加你
|
||||
</button>
|
||||
) : profile.capabilities?.can_add_friend === false ? (
|
||||
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
|
||||
对方暂不可添加
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingFriend(true)}
|
||||
className="h-9 flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm"
|
||||
>
|
||||
添加好友
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
|
||||
查看完整资料
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* A true sibling of the card's own backdrop above, not nested inside it: AddFriendModal is
|
||||
itself a dismissible `fixed inset-0` overlay with its own backdrop-click-to-close handler,
|
||||
and React events bubble -- nesting it inside the card's backdrop (which ALSO closes on any
|
||||
click) would make clicking outside AddFriendModal close both overlays at once instead of
|
||||
just the top one. */}
|
||||
{addingFriend ? (
|
||||
<AddFriendModal
|
||||
onClose={() => setAddingFriend(false)}
|
||||
initialTarget={
|
||||
profile
|
||||
? { user_id: profile.user_id, display_name: profile.display_name, public_account: profile.public_account, avatar_file_id: profile.avatar_file_id }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { submitTenantApplication, uploadPublicFile } from '@/lib/api/tenant';
|
||||
|
||||
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** README §10.5 / old app's EnterpriseRegister -- a company registering itself as a new tenant. */
|
||||
export function EnterpriseRegisterPage() {
|
||||
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
|
||||
const [creditCode, setCreditCode] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [reviewDays, setReviewDays] = useState(1);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!CREDIT_CODE_RE.test(creditCode)) return setError('请输入 18 位统一社会信用代码');
|
||||
if (email && !EMAIL_RE.test(email)) return setError('请输入有效的企业邮箱');
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
let licenseFileId: string | undefined;
|
||||
if (file) {
|
||||
licenseFileId = (await uploadPublicFile(file, 'enterprise_license')).file_id;
|
||||
}
|
||||
const data = await submitTenantApplication({
|
||||
credit_code: creditCode,
|
||||
enterprise_email: email || undefined,
|
||||
license_file_id: licenseFileId,
|
||||
tenant_code_hint: tenantCodeHint,
|
||||
});
|
||||
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
|
||||
setSubmitted(true);
|
||||
setToast('提交成功,我们会尽快联系你');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">企业注册</h1>
|
||||
|
||||
<ol className="mb-6 flex items-center text-xs text-text-tertiary-3">
|
||||
{(['填写企业信息', '确定信息', '提交审核'] as const).map((label, i) => (
|
||||
<li key={label} className="flex flex-1 items-center last:flex-none">
|
||||
<span
|
||||
className={`grid h-[22px] w-[22px] flex-none place-items-center rounded-full text-[11px] ${
|
||||
i === 0 || submitted ? 'bg-accent text-white' : 'border border-[#d5d9df] text-text-tertiary-3'
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className={`ml-1.5 whitespace-nowrap ${i === 0 || submitted ? 'text-text-body' : ''}`}>{label}</span>
|
||||
{i < 2 ? <span className={`mx-2 h-px flex-1 ${submitted ? 'bg-accent' : 'bg-[#d5d9df]'}`} /> : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm text-text-body">统一社会信用代码</label>
|
||||
<div className="flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<input
|
||||
value={creditCode}
|
||||
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
|
||||
placeholder="请输入18位统一社会信用代码"
|
||||
maxLength={18}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
<Icon name="document_scanner" className="flex-none text-[20px] text-focus-border" />
|
||||
</div>
|
||||
<p className="m-0 text-xs text-text-tertiary-3">请填写营业执照上的统一社会信用代码</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm text-text-body">
|
||||
营业执照 <span className="text-text-tertiary-3">选填</span>
|
||||
</label>
|
||||
<label className="flex h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2]">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={(e) => {
|
||||
const selected = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!selected) return;
|
||||
// `accept` is only a file-picker hint, not enforcement -- a user can still pick
|
||||
// an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's
|
||||
// own screenshot validation rather than silently uploading and waiting on the
|
||||
// backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check.
|
||||
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
|
||||
setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setFile(selected);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
<Icon name="upload_file" className="text-[22px] text-accent" />
|
||||
<span className="text-sm text-text-body">{file ? file.name : '点击上传营业执照'}</span>
|
||||
<span className="text-xs text-text-tertiary-3">支持 JPG、PNG、WebP、PDF,不超过 10MB</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm text-text-body">
|
||||
企业邮箱 <span className="text-text-tertiary-3">选填</span>
|
||||
</label>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
placeholder="请输入企业邮箱"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
||||
/>
|
||||
<p className="m-0 text-xs text-text-tertiary-3">用于接收审核结果及企业通知</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitted || submitting || creditCode.length !== 18}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitted ? '已提交' : submitting ? '提交中…' : '提交审核'}
|
||||
</button>
|
||||
<p className="m-0 flex items-center justify-center gap-1 text-xs text-text-tertiary-3">
|
||||
<Icon name="schedule" className="text-[16px]" />
|
||||
资料提交后,预计 {reviewDays} 个工作日完成审核
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { BASE_APP_NAME, FIXED_TENANT_CODE } from '@/lib/config';
|
||||
import { getTinode, loginBasic } from '@/lib/tinode/connection';
|
||||
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Real login failures (wrong password, disabled account, ...) clear the password and keep the
|
||||
// account, matching the product spec -- ported from the old app's own effect for this.
|
||||
useEffect(() => {
|
||||
if (error) setPassword('');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [error]);
|
||||
|
||||
if (!tenant || tenant.code !== FIXED_TENANT_CODE) {
|
||||
// Read tenant before touching getTinode() -- getTinode() lazily creates and PERMANENTLY caches
|
||||
// the singleton off whatever tenant is in the store at that moment (or a hardcoded fallback
|
||||
// host if there's none yet); calling it here first would risk creating that singleton against
|
||||
// the wrong tenant before this redirect even happens.
|
||||
return <Navigate to="/tenant" replace />;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const tinode = getTinode();
|
||||
const ctrl = await loginBasic(login.trim(), password);
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
|
||||
return;
|
||||
}
|
||||
const authToken = tinode.getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId(tinode.getCurrentUserID() || null);
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setError(loginErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full">
|
||||
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
|
||||
<div className="absolute left-16 top-14 flex items-center gap-2.5">
|
||||
<img src="/app-icon.png" alt="" className="h-10 w-10 rounded-[10px]" />
|
||||
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
|
||||
</div>
|
||||
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
|
||||
企业内部沟通,安全、即时、多端同步。
|
||||
</h2>
|
||||
<p className="mt-4 max-w-md text-base text-white/80">
|
||||
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在手机端保持一致。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col bg-white px-6 py-8">
|
||||
<div className="text-sm text-text-secondary">企业码:{FIXED_TENANT_CODE}</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<div className="mb-1 flex flex-col items-center gap-3 min-[900px]:hidden">
|
||||
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
|
||||
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
|
||||
</div>
|
||||
<div className="hidden flex-col gap-1 min-[900px]:flex">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
</div>
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder="请输入用户名/手机号"
|
||||
autoComplete="username"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
|
||||
/>
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder="请输入密码" autoComplete="current-password" />
|
||||
<Link to="/reset-password" className="-mt-2 self-end text-sm text-accent no-underline">
|
||||
忘记密码
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '登录中…' : '登录'}
|
||||
</button>
|
||||
<p className="m-0 text-center text-sm text-text-secondary">
|
||||
还没有账号?<Link to="/register" className="text-accent no-underline">注册账号</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
||||
import { verifyRegistrationCode } from '@/lib/api/auth';
|
||||
import { fileBase, ImApiError } from '@/lib/api/client';
|
||||
import { getTinode, registerBasic } from '@/lib/tinode/connection';
|
||||
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const USERNAME_RE = /^[a-z0-9]{3,10}$/;
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
const CODE_RE = /^(?:\d{6}|[A-Za-z]{5})$/;
|
||||
|
||||
function normalizePhone(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
return (digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits).slice(0, 11);
|
||||
}
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [username, setUsername] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [legalDocument, setLegalDocument] = useState<'terms' | 'privacy' | null>(null);
|
||||
const legalDialogRef = useRef<HTMLDialogElement>(null);
|
||||
const verifiedRef = useRef<{ key: string; response: string } | null>(null);
|
||||
const submitRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = legalDialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (legalDocument && !dialog.open) dialog.showModal();
|
||||
if (!legalDocument && dialog.open) dialog.close();
|
||||
}, [legalDocument]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
// The deployed file endpoint is pinned to the IM API host; the web host serves
|
||||
// unknown /legal/* paths as the SPA index instead of the actual agreements.
|
||||
const legalOrigin = new URL(fileBase(), window.location.href).origin;
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitRef.current) return;
|
||||
setError('');
|
||||
if (!username && !phone) return setError('请填写用户名或手机号');
|
||||
if (username && !USERNAME_RE.test(username)) return setError('用户名须为 3~10 位小写字母或数字');
|
||||
if (phone && !PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
|
||||
if (phone && (!challengeId || !CODE_RE.test(code))) return setError('请先获取验证码并填写 6 位短信验证码或 5 位邀请码');
|
||||
if (password.length < minPasswordLength || (tenant.passwordPolicy?.maxLength && password.length > tenant.passwordPolicy.maxLength)) {
|
||||
return setError(`密码长度须为 ${minPasswordLength}~${tenant.passwordPolicy?.maxLength ?? 64} 位`);
|
||||
}
|
||||
if (password !== confirm) return setError('两次输入的密码不一致');
|
||||
if (!agreed) return setError('请先阅读并同意用户条款与隐私政策');
|
||||
|
||||
submitRef.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let credential: { meth: string; val: string; resp: string; params: { challenge_id: string } } | undefined;
|
||||
if (phone) {
|
||||
const key = `${phone}:${challengeId}:${code}`;
|
||||
let response = verifiedRef.current?.key === key ? verifiedRef.current.response : '';
|
||||
if (!response) {
|
||||
const verified = await verifyRegistrationCode(challengeId, code);
|
||||
response = verified.verification_mode === 'invite_code' ? code : verified.verification_token || '';
|
||||
if (!response) throw new Error('验证结果无效,请重新获取验证码');
|
||||
verifiedRef.current = { key, response };
|
||||
}
|
||||
credential = { meth: 'tel', val: phone, resp: response, params: { challenge_id: challengeId } };
|
||||
}
|
||||
const ctrl = await registerBasic({ username, phone, password, tenantCode: tenant.code, credential });
|
||||
if (ctrl.code >= 300) {
|
||||
setError(ctrl.text === 'validate credentials'
|
||||
? validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)
|
||||
: '注册失败,请检查资料后重试');
|
||||
return;
|
||||
}
|
||||
const tinode = getTinode();
|
||||
const token = tinode.getAuthToken()?.token;
|
||||
const userId = tinode.getCurrentUserID();
|
||||
if (!token || !userId) throw new Error('账号已创建,请返回登录页登录');
|
||||
sessionStorage.setItem('im-session-token', token);
|
||||
setMyUserId(userId);
|
||||
navigate('/messages', { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : loginErrorMessage(err));
|
||||
} finally {
|
||||
submitRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-surface-page">
|
||||
<header className="flex h-14 items-center border-b border-border-panel bg-white px-5">
|
||||
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />返回登录
|
||||
</Link>
|
||||
<span className="ml-auto text-sm text-text-secondary">{tenant.name}</span>
|
||||
</header>
|
||||
<main className="flex flex-1 justify-center px-5 py-8">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[440px] flex-col gap-4">
|
||||
<h1 className="m-0 text-[24px] font-semibold text-text-title">注册账号</h1>
|
||||
{error && <div role="alert" className="rounded-md bg-error-strip-bg px-3 py-2 text-sm text-error-strip-text">{error}</div>}
|
||||
<label className="flex flex-col gap-1 text-sm text-text-secondary">
|
||||
用户名
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))}
|
||||
placeholder="3~10 位小写字母或数字,可不填" autoComplete="username"
|
||||
className="h-[50px] rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm text-text-secondary">
|
||||
手机号
|
||||
<input value={phone} onChange={(e) => {
|
||||
const nextPhone = normalizePhone(e.target.value);
|
||||
if (nextPhone !== phone) {
|
||||
setPhone(nextPhone);
|
||||
setChallengeId('');
|
||||
setCode('');
|
||||
verifiedRef.current = null;
|
||||
}
|
||||
}} placeholder="11 位手机号,可不填"
|
||||
type="tel" inputMode="numeric" autoComplete="tel"
|
||||
className="h-[50px] rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
|
||||
</label>
|
||||
{phone && <div className="flex min-w-0 items-end gap-2">
|
||||
<label className="flex min-w-0 flex-1 flex-col gap-1 text-sm text-text-secondary">
|
||||
验证码或邀请码
|
||||
<input value={code} onChange={(e) => { setCode(e.target.value.slice(0, 6)); verifiedRef.current = null; }}
|
||||
placeholder="6 位短信码 / 5 位邀请码" autoComplete="one-time-code"
|
||||
className="h-[50px] w-full rounded-md bg-surface-input px-3.5 text-[15px] text-text-body outline-none focus:border focus:border-focus-border" />
|
||||
</label>
|
||||
<div className="flex h-[50px] items-center">
|
||||
<VerificationCodeButton phone={phone} scene="register" tenantCode={tenant.code}
|
||||
onChallenge={(id) => { setChallengeId(id); setCode(''); verifiedRef.current = null; }} onError={setError} />
|
||||
</div>
|
||||
</div>}
|
||||
{!phone && username && <p className="m-0 text-xs text-text-secondary">未绑定手机号时无法自助找回密码,请联系企业管理员重置。</p>}
|
||||
<label className="text-sm text-text-secondary">密码</label>
|
||||
<PasswordInput value={password} onChange={setPassword} placeholder={`至少 ${minPasswordLength} 位密码`}
|
||||
autoComplete="new-password" maxLength={tenant.passwordPolicy?.maxLength} />
|
||||
<label className="text-sm text-text-secondary">确认密码</label>
|
||||
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入密码"
|
||||
autoComplete="new-password" maxLength={tenant.passwordPolicy?.maxLength} />
|
||||
<label className="flex items-start gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} className="mt-1" />
|
||||
<span>我已阅读并同意 <button type="button" onClick={() => setLegalDocument('terms')} className="cursor-pointer p-0 text-accent">用户条款</button> 和 <button type="button" onClick={() => setLegalDocument('privacy')} className="cursor-pointer p-0 text-accent">隐私政策</button></span>
|
||||
</label>
|
||||
<button type="submit" disabled={submitting || !agreed || (!username && !phone)}
|
||||
className="h-[50px] rounded-md bg-btn text-base font-medium text-white disabled:bg-btn-disabled">
|
||||
{submitting ? '注册中…' : '注册并登录'}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
<dialog
|
||||
ref={legalDialogRef}
|
||||
onClose={() => setLegalDocument(null)}
|
||||
className="m-auto h-[min(88dvh,800px)] w-[min(92vw,800px)] overflow-hidden rounded-md border border-border-panel bg-white p-0 shadow-xl backdrop:bg-black/50"
|
||||
aria-label={legalDocument === 'privacy' ? '隐私政策' : '用户条款'}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border-panel px-5">
|
||||
<h2 className="m-0 text-base font-semibold text-text-title">{legalDocument === 'privacy' ? '隐私政策' : '用户条款'}</h2>
|
||||
<button type="button" onClick={() => legalDialogRef.current?.close()} aria-label="关闭协议弹窗" title="关闭"
|
||||
className="grid h-9 w-9 place-items-center text-text-secondary hover:text-text-title">
|
||||
<Icon name="close" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{legalDocument && <iframe
|
||||
key={legalDocument}
|
||||
title={legalDocument === 'privacy' ? '隐私政策正文' : '用户条款正文'}
|
||||
src={`${legalOrigin}/legal/${legalDocument === 'privacy' ? 'privacy-policy' : 'user-terms'}`}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
/>}
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { PasswordInput } from '@/features/auth/components/PasswordInput';
|
||||
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
|
||||
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
|
||||
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
|
||||
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
|
||||
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
|
||||
function normalizePhoneInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
|
||||
return withoutCountryCode.slice(0, 11);
|
||||
}
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
// README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE
|
||||
// field error under the password input, not just the shared top banner every other validation
|
||||
// failure here uses -- this mirrors that without touching the other (phone/code) failure paths.
|
||||
const [passwordFieldError, setPasswordFieldError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
// A challengeId/code pair is only valid for the phone number it was issued for -- without this,
|
||||
// editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that
|
||||
// verifyPasswordResetCode() has no way to cross-check against the NEW phone in the field.
|
||||
useEffect(() => {
|
||||
setChallengeId('');
|
||||
setCode('');
|
||||
}, [phone]);
|
||||
|
||||
if (!tenant) return <Navigate to="/tenant" replace />;
|
||||
// Real per-tenant policy (docs/IM-前后端对接文档.md:534) -- was hardcoded to 8 here, not because
|
||||
// anything ever read the real value; SecuritySettingsPage's in-place change had drifted to a
|
||||
// DIFFERENT hardcoded 6.
|
||||
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordFieldError('');
|
||||
setError('');
|
||||
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
|
||||
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
|
||||
// Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy
|
||||
// only enforces a minimum length, so the message says exactly that and nothing more). Inline-only
|
||||
// (no setError) -- these two are the password fields' OWN validation, already shown right under
|
||||
// the relevant input; also raising the shared top banner with the identical text duplicated the
|
||||
// same message on screen twice for no reason phone/code's genuinely-separate failures don't share.
|
||||
if (password.length < minPasswordLength) {
|
||||
return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`);
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return setPasswordFieldError('两次输入的密码不一致');
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const verified = await verifyPasswordResetCode(challengeId, code);
|
||||
await confirmPasswordReset(verified.reset_token, password);
|
||||
setToast('密码修改成功,请重新登录');
|
||||
window.setTimeout(() => navigate('/login'), 1200);
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
|
||||
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link
|
||||
scaled down. */}
|
||||
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
|
||||
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</Link>
|
||||
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
|
||||
</div>
|
||||
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
|
||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />
|
||||
返回登录
|
||||
</Link>
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-8">
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="m-0 hidden text-[22px] font-bold text-text-title min-[900px]:block">重置密码</h1>
|
||||
<p className="m-0 text-sm text-text-tertiary-1">
|
||||
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
|
||||
</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<span className="text-[15px] text-text-tertiary-2">+86</span>
|
||||
<input
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
|
||||
placeholder="请输入手机号"
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete="tel"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="短信验证码"
|
||||
inputMode="numeric"
|
||||
className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
|
||||
/>
|
||||
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(v) => {
|
||||
setPassword(v);
|
||||
setPasswordFieldError('');
|
||||
}}
|
||||
placeholder="新密码"
|
||||
autoComplete="new-password"
|
||||
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
<PasswordInput
|
||||
value={confirm}
|
||||
onChange={(v) => {
|
||||
setConfirm(v);
|
||||
setPasswordFieldError('');
|
||||
}}
|
||||
placeholder="再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
|
||||
maxLength={tenant.passwordPolicy?.maxLength}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!phone || !code || !password || !confirm || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '提交中…' : '确认重置'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { resolveTenant } from '@/lib/api/tenant';
|
||||
import { BASE_APP_NAME, FIXED_TENANT_CODE } from '@/lib/config';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setTenant = useSessionStore((s) => s.setTenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (submitting) return;
|
||||
// Reachable by navigating back here while already logged in (no route guard keeps an
|
||||
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
|
||||
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
|
||||
// Zustand value that resets to null on any fresh mount of this module (a page reload while
|
||||
// sitting on /tenant, or opening this URL directly in a new tab) even though the session token
|
||||
// is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading
|
||||
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
|
||||
// never mounts for this route. Checking the token directly closes that gap.
|
||||
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
|
||||
if (hasLiveSession && !(await requestConfirm({ message: '重新进入将退出当前登录,确定继续吗?' }))) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await resolveTenant(FIXED_TENANT_CODE);
|
||||
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
|
||||
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
|
||||
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
|
||||
// createTinode() only ever reads useSessionStore's tenant at that one construction moment.
|
||||
resetTinode();
|
||||
// The confirm() dialog above literally promises "切换企业将退出当前登录" -- without these,
|
||||
// that was a false promise: `myUserId` is a separate module-level store that switching
|
||||
// tenant never touched, so RequireAuth's `if (myUserId) return <Outlet/>` would let a
|
||||
// subsequent in-app navigation straight through with the OLD tenant's uid and the OLD
|
||||
// tenant's sessionStorage token, against a freshly-reset Tinode connection that has never
|
||||
// authenticated with the NEW tenant at all. Same teardown SettingsLayout's own logout and
|
||||
// SecuritySettingsPage's account deletion already do.
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
queryClient.clear();
|
||||
setTenant({
|
||||
code: res.tenant.code,
|
||||
name: res.tenant.name,
|
||||
endpoints: res.endpoints,
|
||||
host,
|
||||
secure,
|
||||
passwordPolicy: res.password_policy
|
||||
? { minLength: res.password_policy.min_length ?? 8, maxLength: res.password_policy.max_length ?? 64 }
|
||||
: undefined,
|
||||
});
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
|
||||
<Link to="/enterprise-register" state={{ tenantCodeHint: FIXED_TENANT_CODE }} className="absolute right-6 top-6 text-sm text-accent no-underline">
|
||||
企业注册
|
||||
</Link>
|
||||
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
|
||||
<div className="mb-2 flex flex-col items-center gap-3">
|
||||
<img src="/app-icon.png" alt="" className="h-[72px] w-[72px] rounded-2xl shadow-[0_10px_26px_rgba(35,74,125,0.18)]" />
|
||||
<strong className="text-[26px] font-bold text-text-title">{BASE_APP_NAME}</strong>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<Icon name="business" className="text-[20px] text-focus-border" />
|
||||
<input
|
||||
value={FIXED_TENANT_CODE}
|
||||
readOnly
|
||||
aria-label="企业码"
|
||||
className="w-full cursor-not-allowed bg-transparent text-[15px] text-text-secondary outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '进入中…' : '进入企业'}
|
||||
</button>
|
||||
</form>
|
||||
<Link to="/feedback" state={{ tenantCodeHint: FIXED_TENANT_CODE }} className="absolute bottom-8 text-sm text-accent no-underline">
|
||||
遇到问题?
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { submitTenantEntryFeedback, uploadPublicFile } from '@/lib/api/tenant';
|
||||
|
||||
interface PendingImage {
|
||||
file: File;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** README §10.6 / `01/1j`'s 问题反馈, reached from the tenant-code screen's "遇到问题?" link. */
|
||||
export function TenantFeedbackPage() {
|
||||
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
|
||||
const [description, setDescription] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [images, setImages] = useState<PendingImage[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
const addImages = (files: FileList | null) => {
|
||||
const selected = Array.from(files || []).slice(0, 9 - images.length);
|
||||
const valid = selected.filter((f) => f.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(f.type));
|
||||
if (valid.length < selected.length) setError('部分图片未添加:仅支持 JPG/PNG/WebP,且不超过 10MB');
|
||||
setImages((prev) => prev.concat(valid.map((file) => ({ file, url: URL.createObjectURL(file) }))));
|
||||
};
|
||||
|
||||
const removeImage = (i: number) => {
|
||||
setImages((prev) => {
|
||||
URL.revokeObjectURL(prev[i].url);
|
||||
return prev.filter((_, idx) => idx !== i);
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const length = description.trim().length;
|
||||
if (length < 10 || length > 500) return setError('问题描述需为 10 至 500 个字符');
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const screenshotFileIds: string[] = [];
|
||||
for (const item of images) {
|
||||
screenshotFileIds.push((await uploadPublicFile(item.file, 'tenant_entry_feedback')).file_id);
|
||||
}
|
||||
await submitTenantEntryFeedback({
|
||||
description: description.trim(),
|
||||
tenant_code_hint: tenantCodeHint,
|
||||
screenshot_file_ids: screenshotFileIds,
|
||||
contact: contact.trim() || undefined,
|
||||
});
|
||||
setToast('提交成功,我们会尽快联系你');
|
||||
images.forEach((item) => URL.revokeObjectURL(item.url));
|
||||
setDescription('');
|
||||
setImages([]);
|
||||
setContact('');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
|
||||
<Icon name="arrow_back_ios_new" className="text-[16px]" />
|
||||
返回
|
||||
</Link>
|
||||
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">问题反馈</h1>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
|
||||
<Icon name="error_outline" className="text-[18px]" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value.slice(0, 500))}
|
||||
placeholder='企业码输入正确但提示"企业码不存在或已停用"'
|
||||
rows={5}
|
||||
className="resize-none rounded-lg bg-surface-input p-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<span className="self-end text-xs text-text-tertiary-3">{description.length}/500</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2.5">
|
||||
{images.map((img, i) => (
|
||||
<div key={img.url} className="relative aspect-square rounded-lg bg-surface-rail">
|
||||
<img src={img.url} alt="" className="h-full w-full rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -right-1.5 -top-1.5 grid h-[18px] w-[18px] place-items-center rounded-full bg-white text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="cancel" className="text-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{images.length < 9 ? (
|
||||
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2] text-text-tertiary-3">
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(e) => addImages(e.target.files)} className="hidden" />
|
||||
<Icon name="add_photo_alternate" className="text-[20px]" />
|
||||
<span className="text-xs">{images.length}/9</span>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
placeholder="联系方式(选填,手机号或邮箱)"
|
||||
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={description.trim().length < 10 || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '提交中…' : '提交'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<Toast message={toast} onDone={() => setToast('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
autoComplete?: string;
|
||||
error?: string;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export function PasswordInput({ value, onChange, placeholder, autoComplete, error, maxLength }: PasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={`flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
|
||||
error ? 'border border-danger-text' : 'has-focus:border-focus-border'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type={visible ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
maxLength={maxLength}
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
className="text-text-placeholder-2"
|
||||
>
|
||||
<Icon name={visible ? 'visibility' : 'visibility_off'} className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{error ? <div className="text-xs text-danger-border">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { requestVerificationCode } from '@/lib/api/auth';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
|
||||
const PHONE_RE = /^\d{11}$/;
|
||||
|
||||
interface VerificationCodeButtonProps {
|
||||
phone: string;
|
||||
scene: 'reset_password' | 'register';
|
||||
tenantCode?: string;
|
||||
onChallenge: (challengeId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
|
||||
export function VerificationCodeButton({ phone, scene, tenantCode, onChallenge, onError }: VerificationCodeButtonProps) {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
const phoneRef = useRef(phone);
|
||||
|
||||
// Without this, navigating away mid-countdown (e.g. switching from register back to login)
|
||||
// leaves the interval running against an unmounted component indefinitely until it naturally
|
||||
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
|
||||
useEffect(() => () => window.clearInterval(timerRef.current), []);
|
||||
|
||||
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
|
||||
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
|
||||
// counting down for the old number) until the old timer happens to run out on its own.
|
||||
useEffect(() => {
|
||||
phoneRef.current = phone;
|
||||
window.clearInterval(timerRef.current);
|
||||
setSeconds(0);
|
||||
}, [phone]);
|
||||
|
||||
const tick = (initial: number) => {
|
||||
window.clearInterval(timerRef.current);
|
||||
setSeconds(initial);
|
||||
timerRef.current = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
if (s <= 1) {
|
||||
window.clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const click = async () => {
|
||||
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
|
||||
// The phone number this request is actually FOR -- captured now, not read again after the
|
||||
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
|
||||
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
|
||||
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
|
||||
// verification would silently operate on an account the user never intended.
|
||||
const requestedPhone = phone;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await requestVerificationCode(scene, requestedPhone, tenantCode);
|
||||
if (requestedPhone !== phoneRef.current) return;
|
||||
onChallenge(data.challenge_id);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
} catch (err) {
|
||||
if (requestedPhone !== phoneRef.current) return;
|
||||
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
|
||||
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
|
||||
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
|
||||
if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds);
|
||||
if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={click}
|
||||
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
|
||||
className="h-9 w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
|
||||
>
|
||||
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
|
||||
* as "未接听" from the callee's own history and "已取消" from the caller's. */
|
||||
function statusLabel(item: CallHistoryItem): string {
|
||||
const result = item.result || item.status;
|
||||
const incoming = item.direction === 'incoming';
|
||||
if (result === 'finished') return formatDuration(item.duration_ms);
|
||||
if (result === 'missed') return incoming ? '未接听' : '已取消';
|
||||
if (result === 'declined') return '已拒绝';
|
||||
if (result === 'disconnected') return '异常结束';
|
||||
return result;
|
||||
}
|
||||
|
||||
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
|
||||
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
|
||||
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
|
||||
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
|
||||
* to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
|
||||
* a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once
|
||||
* a conversation has real history) is what display-name resolution matches against instead -- the
|
||||
* two are different identifier spaces and matching name lookup against `peer_user_id` was a real,
|
||||
* live-confirmed bug (Zentao #27) that always fell through to the raw uid.
|
||||
*/
|
||||
export function CallHistoryView() {
|
||||
const navigate = useNavigate();
|
||||
const contacts = useChatList();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['call-history'],
|
||||
queryFn: ({ pageParam }) => getCallHistory(pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
|
||||
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
|
||||
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
|
||||
until now (there was no way back from here at all short of a system back gesture). */}
|
||||
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
|
||||
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[18px]" />
|
||||
</Link>
|
||||
<strong className="truncate text-center text-base font-semibold text-text-heading">通话记录</strong>
|
||||
<span />
|
||||
</header>
|
||||
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
|
||||
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
|
||||
</header>
|
||||
<div className="flex flex-col gap-2 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const result = item.result || item.status;
|
||||
const incoming = item.direction === 'incoming';
|
||||
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
|
||||
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
|
||||
// something the viewer missed and should feel warned about. This used to redden EVERY
|
||||
// non-'finished' result alike.
|
||||
const isMissed = result === 'missed' && incoming;
|
||||
// NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id`
|
||||
// is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's
|
||||
// renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the
|
||||
// full mechanism) -- the two are different identifier spaces and this comparison never
|
||||
// matched for any call with real history, always falling through to the raw uid. `item.topic`
|
||||
// is that same `p2p...` form already (confirmed live: identical string to what the chat list
|
||||
// caches for this conversation), so matching on it directly is the correct, direct fix.
|
||||
const name = contacts.find((c) => c.topic === item.topic)?.public?.fn || item.peer_user_id;
|
||||
const ts = item.started_at || item.created_at;
|
||||
return (
|
||||
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
|
||||
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
||||
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
|
||||
{statusLabel(item)}
|
||||
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
|
||||
}}
|
||||
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
|
||||
>
|
||||
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{query.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
className="py-2 text-center text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatCallDuration } from '@/lib/messages/dispatch';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
function useElapsedSeconds(connectedAt: number | null): number {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!connectedAt) {
|
||||
setElapsed(0);
|
||||
return undefined;
|
||||
}
|
||||
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
|
||||
tick();
|
||||
const timer = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [connectedAt]);
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
function formatElapsed(totalSeconds: number): string {
|
||||
const m = Math.floor(totalSeconds / 60);
|
||||
const s = totalSeconds % 60;
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function CircleButton({
|
||||
icon,
|
||||
active,
|
||||
danger,
|
||||
label,
|
||||
onClick,
|
||||
size = 'h-13 w-13',
|
||||
iconSize = 'text-2xl',
|
||||
}: {
|
||||
icon: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
size?: string;
|
||||
iconSize?: string;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
|
||||
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={icon} />
|
||||
</span>
|
||||
<span className="text-xs text-white">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
|
||||
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
|
||||
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
|
||||
* 16px gutter on each side below the point where the fixed width alone would fit. */
|
||||
function IncomingCallCard() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
|
||||
<Icon name="call_end" />
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
|
||||
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
|
||||
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
|
||||
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
|
||||
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
|
||||
function MobileIncomingCall() {
|
||||
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
<span
|
||||
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
|
||||
<div className="mb-16 mt-auto flex gap-24">
|
||||
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
|
||||
<Icon name="call_end" />
|
||||
</span>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
|
||||
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
|
||||
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
|
||||
</span>
|
||||
接听
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
|
||||
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
|
||||
* unmounted between full-screen and minimized). */
|
||||
function MinimizedCallBar() {
|
||||
const { topic, peerName, connectedAt, mediaJoined, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
return (
|
||||
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
|
||||
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
|
||||
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
|
||||
// there's no `sm:hidden` gate on this component to make the overlap moot).
|
||||
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium">{peerName}</strong>
|
||||
{/* `mediaJoined`, not bare `connectedAt` -- signaling accept flips `connectedAt` before
|
||||
media has actually joined (see stores/calls.ts's own comment on this field), so this
|
||||
used to claim "通话中" for a call whose mic never actually attached. */}
|
||||
<span className="text-xs text-success">{connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
|
||||
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
|
||||
</button>
|
||||
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
|
||||
<Icon name="call_end" className="text-base" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
|
||||
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
|
||||
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
|
||||
* cross-window signaling bus this rewrite has no other need for. */
|
||||
function CallWindow() {
|
||||
const {
|
||||
topic,
|
||||
callType,
|
||||
phase,
|
||||
peerName,
|
||||
connectedAt,
|
||||
mediaJoined,
|
||||
muted,
|
||||
cameraOff,
|
||||
hasRemoteVideo,
|
||||
hasLocalVideo,
|
||||
minimized,
|
||||
mediaError,
|
||||
toggleMinimize,
|
||||
toggleMute,
|
||||
toggleCamera,
|
||||
hangUp,
|
||||
} = useCallStore();
|
||||
const remoteRef = useRef<HTMLDivElement>(null);
|
||||
const localRef = useRef<HTMLDivElement>(null);
|
||||
const elapsed = useElapsedSeconds(connectedAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (remoteRef.current) playRemoteVideo(remoteRef.current);
|
||||
}, [hasRemoteVideo]);
|
||||
useEffect(() => {
|
||||
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
|
||||
}, [hasLocalVideo, cameraOff]);
|
||||
|
||||
if (minimized) return <MinimizedCallBar />;
|
||||
|
||||
// Signaling `connected` and actual media both being up are two different things (see stores/
|
||||
// calls.ts's `mediaJoined` comment) -- a real "网络良好" claim needs the second one too, and the
|
||||
// brief signaling-only window in between gets its own label rather than either overstating the
|
||||
// connection or reusing the pre-answer ringing text.
|
||||
const statusLabel = phase === 'connected' ? (mediaJoined ? `${formatElapsed(elapsed)} · 网络良好` : '正在连接中…') : '正在等待对方接听…';
|
||||
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
|
||||
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
|
||||
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
|
||||
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
|
||||
|
||||
if (callType === 'audio') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
|
||||
<Icon name="expand_more" className="text-2xl" />
|
||||
</button>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
|
||||
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
|
||||
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-center text-xl font-semibold">{peerName}</strong>
|
||||
<span className="text-sm text-white/60">{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex gap-8">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] text-white"
|
||||
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
|
||||
>
|
||||
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
|
||||
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
|
||||
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
|
||||
z-index, so plain DOM order would decide stacking and the video (which comes second) would
|
||||
paint over this header the moment real remote video is actually playing. That case never
|
||||
got exercised live (this sandbox has no real camera, see the calls verification note in the
|
||||
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
|
||||
tests a real 2-camera call to rediscover. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold">{peerName}</strong>
|
||||
<span className="flex items-center gap-1 text-xs text-white/70">
|
||||
{phase === 'connected' ? (
|
||||
mediaJoined ? (
|
||||
<>
|
||||
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
|
||||
</>
|
||||
) : (
|
||||
'正在连接中…'
|
||||
)
|
||||
) : (
|
||||
'正在等待对方接听…'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-white/80">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
|
||||
those move into the centered overlay below (no remote video yet) or the small pill just
|
||||
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
|
||||
control grid, matching the mock exactly. */}
|
||||
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
|
||||
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
|
||||
<Icon name="picture_in_picture_alt" className="text-xl" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void useCallStore.getState().switchCamera()}
|
||||
aria-label="翻转摄像头"
|
||||
className="grid h-10 w-10 place-items-center"
|
||||
>
|
||||
<Icon name="flip_camera_ios" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{hasRemoteVideo ? (
|
||||
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
|
||||
{peerName}
|
||||
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="absolute inset-0">
|
||||
{hasRemoteVideo ? (
|
||||
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
|
||||
<div className="hidden h-full w-full place-items-center md:grid">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
|
||||
<span
|
||||
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
|
||||
has no header info bar to carry the name instead. */}
|
||||
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
|
||||
<span
|
||||
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
|
||||
<span className="mt-1 text-sm text-white/70">
|
||||
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
|
||||
</span>
|
||||
{/* Desktop's version of this same fallback state shows this explanation (line 285
|
||||
above); mobile silently dropped it, showing only the elapsed-time line with no
|
||||
indication of WHY there's no video. */}
|
||||
{phase === 'connected' && mediaJoined ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
|
||||
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
|
||||
responsive classes, restoring the desktop values under `md:`. */}
|
||||
{!cameraOff ? (
|
||||
<div
|
||||
ref={localRef}
|
||||
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
|
||||
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
|
||||
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
|
||||
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
|
||||
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
|
||||
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
|
||||
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<CircleButton
|
||||
icon={cameraOff ? 'videocam_off' : 'videocam'}
|
||||
active={cameraOff}
|
||||
label="摄像头"
|
||||
onClick={toggleCamera}
|
||||
size="h-15 w-15"
|
||||
iconSize="text-[26px]"
|
||||
/>
|
||||
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
|
||||
<span />
|
||||
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
|
||||
<span />
|
||||
</div>
|
||||
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
|
||||
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
|
||||
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
|
||||
function EndedCallOverlay() {
|
||||
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
|
||||
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
|
||||
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
|
||||
// render would make the displayed value drift depending on when React happens to re-render.
|
||||
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
|
||||
const icon = callType === 'video' ? 'videocam' : 'call';
|
||||
const { label, danger } = (() => {
|
||||
switch (endReason) {
|
||||
case 'finished':
|
||||
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
|
||||
case 'missed':
|
||||
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
|
||||
// `wasCaller` here is `false` exactly when this device is the one that just clicked
|
||||
// "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed`
|
||||
// right above already makes, "对方已拒绝" is backwards on the decliner's own screen.
|
||||
case 'declined':
|
||||
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
|
||||
case 'disconnected':
|
||||
return { label: '通话连接异常,已结束', danger: true };
|
||||
case 'busy':
|
||||
return { label: '对方忙线中', danger: true };
|
||||
default:
|
||||
return { label: '通话已结束', danger: false };
|
||||
}
|
||||
})();
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
|
||||
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
|
||||
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
|
||||
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<strong className="text-xl font-semibold">{peerName}</strong>
|
||||
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
|
||||
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mounted once in AppShell. */
|
||||
export function CallRoot() {
|
||||
const { phase, minimized } = useCallStore();
|
||||
|
||||
if (phase === 'incoming')
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:contents">
|
||||
<IncomingCallCard />
|
||||
</div>
|
||||
<div className="contents md:hidden">
|
||||
<MobileIncomingCall />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
|
||||
if (phase === 'ended') return <EndedCallOverlay />;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { QRScannerModal } from '@/features/contacts/QRScannerModal';
|
||||
import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends';
|
||||
import { submitGroupJoinRequest } from '@/lib/api/groups';
|
||||
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 50;
|
||||
|
||||
interface AddFriendModalProps {
|
||||
onClose: () => void;
|
||||
onSent?: () => void;
|
||||
/** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the
|
||||
* search step to the verification-message step, rather than making the user re-search someone
|
||||
* whose profile is already open. */
|
||||
initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see
|
||||
* lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays
|
||||
* the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since
|
||||
* sharing a friend's card into a conversation has no backend support anywhere. Search result rows
|
||||
* drive their state from
|
||||
* `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to
|
||||
* read a label off of): already a friend or already pending are shown but not selectable, `can_add:
|
||||
* false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only
|
||||
* a genuinely addable result can be picked before writing the verification message.
|
||||
*/
|
||||
export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [searchedFor, setSearchedFor] = useState('');
|
||||
const [selected, setSelected] = useState<UserSearchResult | null>(
|
||||
initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null,
|
||||
);
|
||||
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
|
||||
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
|
||||
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
|
||||
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [sent, setSent] = useState(false);
|
||||
const [showMyQrCode, setShowMyQrCode] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
// A scanned `purpose:"join_group"` code -- a completely different target shape from `selected`
|
||||
// (a person), so it gets its own state rather than being forced into UserSearchResult's fields.
|
||||
const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const handleScanResolved = (user: QRResolveUser) => {
|
||||
setScanning(false);
|
||||
setSelectedSource('qr');
|
||||
setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true });
|
||||
};
|
||||
const handleScanAlreadyFriend = (user: QRResolveUser) => {
|
||||
setScanning(false);
|
||||
onClose();
|
||||
navigate(`/contacts/${user.user_id}`);
|
||||
};
|
||||
const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => {
|
||||
setScanning(false);
|
||||
setGroupJoinTarget({ ...group, qrToken });
|
||||
};
|
||||
const handleScanAlreadyMember = (group: QRResolveGroup) => {
|
||||
setScanning(false);
|
||||
onClose();
|
||||
navigate(`/messages/${group.topic}`);
|
||||
};
|
||||
|
||||
// docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` --
|
||||
// the default `type=account` never matches a bare phone number, so a search box that always sends
|
||||
// `account` can never satisfy README §5.4's own placeholder implying phone-number search works.
|
||||
const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account';
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['user-search', searchedFor, searchType],
|
||||
queryFn: () => searchUsers(searchedFor, searchType),
|
||||
enabled: !!searchedFor,
|
||||
});
|
||||
|
||||
const runSearch = () => {
|
||||
setSelected(null);
|
||||
setSearchedFor(query.trim());
|
||||
};
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
setSent(true);
|
||||
onSent?.();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'),
|
||||
});
|
||||
|
||||
const groupJoinMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }),
|
||||
onSuccess: () => setSent(true),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'),
|
||||
});
|
||||
|
||||
const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => {
|
||||
if (item.relationship === 'friend') return { label: '已是好友', selectable: false };
|
||||
// docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending"
|
||||
// (comparing against that literal never matched either real value, so a search result with an
|
||||
// outstanding request either direction fell through to the `can_add` check below and could look
|
||||
// fully selectable, risking a duplicate friend-requests submit).
|
||||
if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false };
|
||||
if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false };
|
||||
if (!item.can_add) return { label: '无法添加', selectable: false };
|
||||
return { label: '', selectable: true };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[80vh] w-[520px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">添加好友</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
{sent ? (
|
||||
<div className="flex flex-col items-center gap-3 px-5 py-16">
|
||||
<Icon name="check_circle" className="text-[40px] text-success" />
|
||||
<span className="text-sm text-text-body">申请已发送</span>
|
||||
<button type="button" onClick={onClose} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{groupJoinTarget ? (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg bg-accent text-sm font-medium text-white">
|
||||
{groupJoinTarget.name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{groupJoinTarget.name}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">{groupJoinTarget.member_count} 人</span>
|
||||
</span>
|
||||
</div>
|
||||
) : initialTarget ? (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
|
||||
<Avatar
|
||||
id={initialTarget.user_id}
|
||||
name={initialTarget.display_name}
|
||||
avatarFileId={initialTarget.avatar_file_id}
|
||||
className="h-10 w-10 flex-none rounded-lg text-sm"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{initialTarget.display_name}</strong>
|
||||
{initialTarget.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {initialTarget.public_account}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 flex-1 items-center rounded-lg border border-[#d5d9df] px-3 focus-within:border-accent focus-within:shadow-[0_0_0_3px_rgba(22,119,255,0.1)]">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
|
||||
placeholder="手机号/账号/昵称"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={runSearch} disabled={!query.trim()} className="flex-none text-sm text-accent disabled:opacity-40">
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) },
|
||||
{ icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) },
|
||||
{ icon: 'share', label: '分享好友', onClick: notImplemented },
|
||||
].map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
|
||||
>
|
||||
<Icon name={action.icon} className="text-xl text-accent" />
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!initialTarget && !groupJoinTarget && searchedFor ? (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{searchQuery.isLoading ? (
|
||||
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : searchQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-6 text-sm text-danger-text">
|
||||
<span>搜索失败,请重试</span>
|
||||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !searchQuery.data?.items.length ? (
|
||||
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
|
||||
) : (
|
||||
searchQuery.data.items.map((item) => {
|
||||
const { label, selectable } = rowState(item);
|
||||
const isSelected = selected?.user_id === item.user_id;
|
||||
return (
|
||||
<button
|
||||
key={item.user_id}
|
||||
type="button"
|
||||
disabled={!selectable}
|
||||
onClick={() => {
|
||||
// Real, live-confirmed backend validation (im_http.go's
|
||||
// serveIMFriendRequestCreate): the `source` field's switch only recognizes
|
||||
// `search`/`qr`/`group` -- there is no `phone` case, so it falls to
|
||||
// `default` and 400s with "invalid friend request source". A prior pass
|
||||
// here tried to report phone-originated searches as their own `phone`
|
||||
// source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
|
||||
// which broke every phone-search-based friend request outright -- reproduced
|
||||
// live with two brand-new accounts before this fix (first-ever request,
|
||||
// never a retry/idempotency issue as it first looked). Phone and account
|
||||
// search both go through the same `search` channel from the backend's own
|
||||
// point of view; there is no server-side data to recover that distinction
|
||||
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
|
||||
setSelectedSource('search');
|
||||
setSelected(item);
|
||||
}}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
|
||||
>
|
||||
<Avatar id={item.user_id} name={item.display_name} avatarFileId={item.avatar_file_id} className="h-10 w-10 flex-none rounded-lg text-sm" />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{item.display_name}</strong>
|
||||
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
|
||||
</span>
|
||||
{selectable ? (
|
||||
<span className={`flex-none rounded-md px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
|
||||
{isSelected ? '已选择' : '选择'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selected || groupJoinTarget ? (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-sm text-text-body">{groupJoinTarget ? '申请留言' : '验证留言'}</div>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
|
||||
rows={3}
|
||||
placeholder={groupJoinTarget ? '申请入群' : `我是${selected?.display_name}的朋友`}
|
||||
className="w-full resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{sent ? null : (
|
||||
<footer className="flex flex-none items-center justify-end gap-2 border-t border-border-list-item px-5 py-3">
|
||||
<button type="button" onClick={onClose} className="h-9 rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (groupJoinTarget ? groupJoinMutation.mutate() : sendMutation.mutate())}
|
||||
disabled={(!selected && !groupJoinTarget) || sendMutation.isPending || groupJoinMutation.isPending}
|
||||
className="h-9 rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{sendMutation.isPending || groupJoinMutation.isPending ? '发送中…' : groupJoinTarget ? '申请加入' : '发送申请'}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Siblings of the backdrop above, not nested inside it -- their own backdrop-click-to-close
|
||||
would otherwise bubble up and close THIS modal too, since only the 520px card (not the
|
||||
backdrop) stops propagation. */}
|
||||
{showMyQrCode ? <QRCodeModal onClose={() => setShowMyQrCode(false)} /> : null}
|
||||
{scanning ? (
|
||||
<QRScannerModal
|
||||
onClose={() => setScanning(false)}
|
||||
onResolved={handleScanResolved}
|
||||
onAlreadyFriend={handleScanAlreadyFriend}
|
||||
onGroupResolved={handleScanGroupResolved}
|
||||
onAlreadyMember={handleScanAlreadyMember}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
|
||||
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
|
||||
*
|
||||
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
|
||||
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
|
||||
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
|
||||
* already established for its own multi-select, so it's consistent with something this app already does,
|
||||
* not a one-off design.
|
||||
*/
|
||||
export function BlocklistView() {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const unblockMutation = useMutation({
|
||||
mutationFn: (userId: string) => unblockUser(userId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
|
||||
});
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const bulkUnblockMutation = useMutation({
|
||||
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
|
||||
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
|
||||
// unblock while the rest succeed. Silently treating this as full success (the same pattern
|
||||
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
|
||||
// on the blocklist with no indication anything went wrong.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
|
||||
exitSelectMode();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
|
||||
});
|
||||
|
||||
const toggleSelected = (userId: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.add(userId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const items = query.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
{selectMode ? (
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
|
||||
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="remove_circle_outline" className="text-[18px]" />
|
||||
移出
|
||||
</button>
|
||||
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
) : (
|
||||
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="flex-1 text-base font-semibold text-text-heading">
|
||||
黑名单{items.length ? ` ${items.length}` : ''}
|
||||
</strong>
|
||||
{items.length > 1 ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
)}
|
||||
<div className="flex flex-col gap-2.5 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
|
||||
) : (
|
||||
items.map((b) => (
|
||||
<div
|
||||
key={b.user.user_id}
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
|
||||
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
|
||||
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{selectMode ? <SelectionCheckbox selected={selected.has(b.user.user_id)} /> : null}
|
||||
<Avatar
|
||||
id={b.user.user_id}
|
||||
name={b.user.display_name}
|
||||
avatarFileId={b.user.avatar_file_id}
|
||||
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
|
||||
</span>
|
||||
</span>
|
||||
{selectMode ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unblockMutation.mutate(b.user.user_id)}
|
||||
disabled={unblockMutation.isPending}
|
||||
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
移出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{items.length ? (
|
||||
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
|
||||
import { ContactsListColumn } from '@/features/contacts/ContactsListColumn';
|
||||
|
||||
/**
|
||||
* README §5.1's 3-column split (NavRail is AppShell's own, rendered above this): list column always
|
||||
* docked (unlike messages, contacts has no detail-panel breakpoint to collapse), main area is
|
||||
* whatever `/contacts/*` sub-route is active. Mirrors MessagesLayout's ChatListColumn+Outlet shape,
|
||||
* minus the detail-panel/history-modal machinery messages needs and contacts doesn't.
|
||||
*
|
||||
* `showListOnNarrow` can't key off a route param the way MessagesLayout keys off `topicId` --
|
||||
* `/contacts/requests|groups|blocklist` are sibling routes with no param at all, not variants of
|
||||
* `:userId`, so this checks the exact index path instead.
|
||||
*/
|
||||
export function ContactsLayout() {
|
||||
const location = useLocation();
|
||||
const showListOnNarrow = location.pathname === '/contacts';
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ContactsListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
|
||||
|
||||
const FIXED_ENTRIES = [
|
||||
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
|
||||
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
|
||||
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
|
||||
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
|
||||
// §5.1's desktop entry list doesn't include it.
|
||||
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
|
||||
] as const;
|
||||
|
||||
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
|
||||
* library; falls back to the display name's own first character for the rare item missing one. */
|
||||
function groupInitial(name: string, initial: string | undefined): string {
|
||||
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
|
||||
return /[A-Z]/.test(raw) ? raw : '#';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.1 (320px): search + fixed entries (新朋友/群聊/黑名单, 文件助手 removed -- see
|
||||
* ChatListColumn.tsx's PINNED_ENTRIES doc comment for why) + letter-grouped
|
||||
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
|
||||
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
|
||||
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
|
||||
*/
|
||||
export function ContactsListColumn() {
|
||||
const { userId } = useParams();
|
||||
const [query, setQuery] = useState('');
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
|
||||
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
|
||||
const requestsQuery = useQuery({
|
||||
queryKey: ['friend-requests', 'inbound', 'pending'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
|
||||
});
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const friends = friendsQuery.data || [];
|
||||
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byLetter = new Map<string, typeof filtered>();
|
||||
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
|
||||
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
|
||||
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
|
||||
}
|
||||
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
|
||||
}, [filtered]);
|
||||
|
||||
const pendingRequestCount = requestsQuery.data?.length ?? 0;
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
<header className="flex h-15 flex-none items-center justify-between px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
|
||||
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-10 w-10 place-items-center text-text-heading md:h-9 md:w-9">
|
||||
<Icon name="person_add_alt" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索联系人"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{trimmedQuery ? null : (
|
||||
<>
|
||||
{FIXED_ENTRIES.map((entry) => {
|
||||
const badge =
|
||||
entry.key === 'requests'
|
||||
? pendingRequestCount
|
||||
: entry.key === 'blocklist'
|
||||
? (blocksQuery.data?.length ?? 0)
|
||||
: 0;
|
||||
return (
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
|
||||
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
|
||||
}`}
|
||||
>
|
||||
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
|
||||
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-xl" />
|
||||
</span>
|
||||
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
|
||||
{entry.key === 'requests' && badge ? (
|
||||
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{badge}
|
||||
</b>
|
||||
) : entry.key === 'groups' ? (
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
) : entry.key === 'blocklist' && badge ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : friendsQuery.isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => friendsQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
|
||||
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
|
||||
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(([letter, items]) => (
|
||||
<div key={letter} id={`contact-letter-${letter}`}>
|
||||
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
|
||||
{items.map((f) => {
|
||||
const name = f.remark || f.display_name || f.user_id;
|
||||
const active = userId === f.user_id;
|
||||
return (
|
||||
<Link
|
||||
key={f.user_id}
|
||||
to={`/contacts/${f.user_id}`}
|
||||
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
|
||||
>
|
||||
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
|
||||
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
|
||||
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
|
||||
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{trimmedQuery || groups.length === 0 ? null : (
|
||||
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
|
||||
{groups.map(([letter]) => (
|
||||
<a
|
||||
key={letter}
|
||||
href={`#contact-letter-${letter}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
|
||||
}}
|
||||
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
export function EmptyContactsMain() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
|
||||
<Icon name="person_search" className="!text-5xl" />
|
||||
<span className="text-sm">选择一位联系人查看资料</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getUserProfile, updateFriendRemark } from '@/lib/api/friends';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
|
||||
|
||||
/**
|
||||
* README §5.2 (440px centered card). `getUserProfile` (doc line 1328/1425) has no field describing
|
||||
* HOW two people became friends -- the design's "来源:通过群聊「项目协作群」添加" row has nothing
|
||||
* to read it off of, so it's left out rather than fabricated; every other row maps to a real field.
|
||||
*/
|
||||
export function FriendProfileView() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingRemark, setEditingRemark] = useState(false);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['user-profile', userId],
|
||||
queryFn: () => getUserProfile(userId || ''),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const remarkMutation = useMutation({
|
||||
mutationFn: (remark: string) => updateFriendRemark(userId || '', remark),
|
||||
onSuccess: (_data, remark) => {
|
||||
queryClient.setQueryData(['user-profile', userId], (prev: typeof query.data) => (prev ? { ...prev, remark } : prev));
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
setEditingRemark(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '备注保存失败'),
|
||||
});
|
||||
|
||||
// `/contacts/:userId` is a route only reachable via a route change (list item, search result),
|
||||
// never a param variant of the index -- so ContactsLayout's showListOnNarrow hides the list here
|
||||
// just like every other narrow-viewport sub-route, and this page needs the same back affordance
|
||||
// GroupListView/NewFriendsView/BlocklistView have in their own header. This one has no header bar
|
||||
// at any state (loading/not-found/loaded), so the link is repeated across all three returns.
|
||||
const backLink = (
|
||||
<Link to="/contacts" className="absolute left-4 top-4 z-10 grid h-9.5 w-9.5 place-items-center rounded-full bg-white/70 text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (!userId) return null;
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
加载中…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const profile = query.data;
|
||||
if (!profile) {
|
||||
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
|
||||
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
|
||||
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
|
||||
// as if the user doesn't exist.
|
||||
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
|
||||
// A genuine 404 is the only case that should actually read as "this user doesn't exist" --
|
||||
// every OTHER failure (network error, 5xx, a transient timeout) used to fall into that same
|
||||
// "未找到该用户" bucket too, telling the user an account is gone/never existed when the real
|
||||
// problem is just that this one request failed and could succeed on retry.
|
||||
const isNotFound = query.error instanceof ImApiError && query.error.status === 404;
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
{isPrivate ? (
|
||||
'对方设置了资料保护,暂时无法查看'
|
||||
) : isNotFound ? (
|
||||
'未找到该用户'
|
||||
) : (
|
||||
<>
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFriend = profile.relationship === 'friend';
|
||||
// docs/IM-前后端对接文档.md:1289's real enum is direction-specific -- `isFriend` alone used to
|
||||
// collapse BOTH pending states into "not a friend yet", which fell through to the generic "添加
|
||||
// 好友" button below and let the user re-open AddFriendModal and resubmit a request that's already
|
||||
// outstanding either direction.
|
||||
const isPendingOutbound = profile.relationship === 'pending_outbound';
|
||||
const isPendingInbound = profile.relationship === 'pending_inbound';
|
||||
const name = profile.remark || profile.display_name;
|
||||
const metaParts = [profile.display_name ? `昵称:${profile.display_name}` : null, profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
// README §5.2 (desktop, 44px) vs §5.7 (mobile, 48px) want the SAME three actions at two different
|
||||
// sizes in two different places (inline in the card vs a fixed bottom bar, see below) -- one
|
||||
// function instead of duplicating the branching/handlers so there's exactly one behavior to get
|
||||
// right, not two copies that can drift.
|
||||
const actionButtons = (squareBtnClass: string, iconSizeClass: string) =>
|
||||
isFriend ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
// `profile.user_id`, not `profile.topic` -- confirmed live (see favorites.ts's own
|
||||
// finding) that this API's `topic` field for a P2P relationship is the server's
|
||||
// internal `p2p...`-hashed form, which this app's chat list/router never uses;
|
||||
// every P2P conversation elsewhere in this app is addressed by the peer's own
|
||||
// `usr...` id, which Tinode resolves to the same real topic on the wire.
|
||||
onClick={() => navigate(`/messages/${profile.user_id}`)}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
|
||||
>
|
||||
<Icon name="chat_bubble" className={iconSizeClass} />
|
||||
发消息
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'audio');
|
||||
}}
|
||||
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
|
||||
>
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profile.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'video');
|
||||
}}
|
||||
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
|
||||
>
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
</>
|
||||
) : isPendingOutbound ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
等待验证
|
||||
</button>
|
||||
) : isPendingInbound ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
对方已申请加你
|
||||
</button>
|
||||
) : profile.capabilities?.can_add_friend === false ? (
|
||||
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
|
||||
对方暂不可添加
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingFriend(true)}
|
||||
className={`flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
|
||||
>
|
||||
添加好友
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center-safe justify-center overflow-y-auto bg-surface-content md:py-12">
|
||||
{backLink}
|
||||
<div className="w-full flex-none overflow-hidden bg-white shadow-[0_8px_32px_rgba(35,74,125,0.08)] md:w-[440px] md:rounded-2xl">
|
||||
<div className="h-24 bg-gradient-to-br from-[#e8f1fe] to-[#f3efff]" />
|
||||
<div className="flex flex-col items-center px-6 pb-24 md:pb-6">
|
||||
<Avatar
|
||||
id={profile.user_id}
|
||||
name={name}
|
||||
avatarFileId={profile.avatar_file_id}
|
||||
className="-mt-11 h-22 w-22 flex-none rounded-[20px] border-4 border-white text-2xl"
|
||||
/>
|
||||
<strong className="mt-3 text-xl font-semibold text-text-heading">{name}</strong>
|
||||
{metaParts ? <span className="mt-1 text-[13px] text-text-tertiary-2">{metaParts}</span> : null}
|
||||
{profile.public_account ? <span className="text-[13px] text-text-tertiary-2">ID: {profile.public_account}</span> : null}
|
||||
{profile.signature ? <p className="m-0 mt-2 text-sm text-text-secondary">{profile.signature}</p> : null}
|
||||
|
||||
{/* README §5.7: mobile pins this row OUTSIDE the scroll area (bottom-fixed), so it stays
|
||||
reachable regardless of profile length -- this hidden copy keeps the original inline
|
||||
desktop placement exactly as before; the real mobile copy is the fixed bar rendered
|
||||
after the card below. Both call the same handlers, so there is exactly one behavior. */}
|
||||
<div className="mt-5 hidden w-full items-center gap-2 md:flex">{actionButtons('h-11 w-11', 'text-[18px]')}</div>
|
||||
|
||||
{isFriend ? (
|
||||
<div className="mt-4 flex w-full flex-col divide-y divide-border-list-item border-t border-border-list-item text-sm">
|
||||
{editingRemark ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={remarkDraft}
|
||||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft.trim())}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft.trim())} className="flex-none text-accent">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRemarkDraft(profile.remark || '');
|
||||
setEditingRemark(true);
|
||||
}}
|
||||
className="flex justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">备注</span>
|
||||
<span className="flex items-center gap-0.5 text-text-tertiary-2">
|
||||
{profile.remark || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[18px]" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setShowQrCode(true)} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">二维码名片</span>
|
||||
<Icon name="qr_code_2" className="text-[20px] text-text-tertiary-2" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile-only fixed bottom bar (mock `04/1e`: `padding:12px 16px 34px`, 48px buttons) -- the
|
||||
real mobile counterpart to the hidden desktop copy above, pinned to the viewport bottom
|
||||
(not the card) so it stays reachable no matter how far the profile content scrolls.
|
||||
`bottom-[82px] sm:bottom-0`: `position:fixed` resolves against the viewport, not this
|
||||
route's own Outlet box, and below 640px the viewport bottom is where AppShell's persistent
|
||||
BottomTabBar (82px) already lives -- an unqualified `bottom-0` sat directly on top of it,
|
||||
hiding the tab bar entirely while this page was open. `sm:bottom-0` restores flush-bottom
|
||||
once the tab bar is gone (>=640px, NavRail shown on the side instead). */}
|
||||
<div className="fixed inset-x-0 bottom-[82px] z-10 flex gap-3 bg-white px-4 pb-[34px] pt-3 sm:bottom-0 md:hidden">
|
||||
{actionButtons('h-12 w-12', 'text-xl')}
|
||||
</div>
|
||||
|
||||
{showQrCode && userId ? <QRCodeModal identifier={userId} onClose={() => setShowQrCode(false)} /> : null}
|
||||
{addingFriend ? (
|
||||
<AddFriendModal
|
||||
onClose={() => setAddingFriend(false)}
|
||||
initialTarget={{
|
||||
user_id: profile.user_id,
|
||||
display_name: profile.display_name,
|
||||
public_account: profile.public_account,
|
||||
avatar_file_id: profile.avatar_file_id,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getGroups, type GroupRole } from '@/lib/api/groups';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
type Tab = 'all' | 'owner' | 'admin' | 'member';
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'owner', label: '我创建的' },
|
||||
{ key: 'admin', label: '我管理的' },
|
||||
{ key: 'member', label: '我加入的' },
|
||||
];
|
||||
|
||||
/** README §5.5. Fetched once (category='', i.e. every group) and tabbed client-side by `my_role`
|
||||
* rather than hitting `getGroups`'s own owned/managed/joined category filters three more times --
|
||||
* same total data, one request instead of four. */
|
||||
export function GroupListView() {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
// Only page 1 used to be fetched -- an account with more than one page of groups would silently
|
||||
// undercount every tab (including "全部") and never list the rest.
|
||||
const query = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
|
||||
const groups = query.data || [];
|
||||
const counts: Record<Tab, number> = {
|
||||
all: groups.length,
|
||||
owner: groups.filter((g) => g.my_role === 'owner').length,
|
||||
admin: groups.filter((g) => g.my_role === 'admin').length,
|
||||
member: groups.filter((g) => g.my_role === 'member').length,
|
||||
};
|
||||
const filtered = tab === 'all' ? groups : groups.filter((g) => g.my_role === tab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="flex items-center gap-1">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="text-base font-semibold text-text-heading">群聊</strong>
|
||||
</span>
|
||||
<button type="button" onClick={() => setCreatingGroup(true)} className="flex items-center gap-1 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
创建群聊
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-none items-center gap-6 border-b border-border-panel bg-white px-6">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`h-11 border-b-2 text-sm ${tab === t.key ? 'border-accent font-medium text-accent' : 'border-transparent text-text-secondary'}`}
|
||||
>
|
||||
{t.label} {counts[t.key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
|
||||
) : (
|
||||
filtered.map((g) => (
|
||||
<button
|
||||
key={g.topic}
|
||||
type="button"
|
||||
onClick={() => navigate(`/messages/${g.topic}`)}
|
||||
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 text-left shadow-bubble"
|
||||
>
|
||||
<Avatar id={g.topic} name={g.name} avatarFileId={g.avatar_file_id} className="h-11 w-11 flex-none rounded-[10px] text-base" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{g.name}</strong>
|
||||
{g.my_role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${g.my_role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'}`}
|
||||
>
|
||||
{ROLE_LABEL[g.my_role]}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty
|
||||
group (confirmed live -- same backend dual-counter split as ChatListColumn's own
|
||||
"N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so
|
||||
an exact zero here means "don't trust this field", not "empty group". */}
|
||||
{g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic}
|
||||
</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{creatingGroup ? (
|
||||
<CreateGroupModal
|
||||
onClose={() => setCreatingGroup(false)}
|
||||
onCreated={(topic) => {
|
||||
setCreatingGroup(false);
|
||||
navigate(`/messages/${topic}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type FriendRequest } from '@/lib/api/friends';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
|
||||
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
|
||||
// same `search` value an account-name search does, since the server has no separate channel for it
|
||||
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
|
||||
// tried to report `phone` as its own source instead).
|
||||
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
|
||||
|
||||
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
|
||||
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
|
||||
* that, not a pending-only fetch. */
|
||||
export function NewFriendsView() {
|
||||
const queryClient = useQueryClient();
|
||||
const [addingFriend, setAddingFriend] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
|
||||
|
||||
// Only page 1 used to be fetched -- an account with more than one page of historical requests
|
||||
// would silently never show its older ones.
|
||||
const query = useQuery({
|
||||
queryKey: ['friend-requests', 'all'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'all', cursor)),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: (req: FriendRequest) => acceptFriendRequest(req.request_id),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
|
||||
});
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (req: FriendRequest) => rejectFriendRequest(req.request_id),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
|
||||
});
|
||||
|
||||
const items = query.data || [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<span className="flex items-center gap-1">
|
||||
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<strong className="text-base font-semibold text-text-heading">新朋友</strong>
|
||||
</span>
|
||||
<button type="button" onClick={() => setAddingFriend(true)} className="text-sm text-accent">
|
||||
添加好友
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
|
||||
) : (
|
||||
items.map((req) => {
|
||||
const status = handled[req.request_id] || (req.status !== 'pending' ? (req.status === 'accepted' ? 'accepted' : 'rejected') : null);
|
||||
const name = req.peer?.display_name || req.peer?.user_id || req.applicant_id;
|
||||
return (
|
||||
<div key={req.request_id} className="flex items-center gap-3 rounded-xl bg-white px-4.5 py-3.5 shadow-bubble">
|
||||
<span className={`grid h-12 w-12 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.applicant_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
|
||||
{req.message ? <span className="truncate text-[13px] text-text-secondary">{req.message}</span> : null}
|
||||
<span className="truncate text-xs text-text-tertiary-3">
|
||||
{formatEventTimestamp(new Date(req.created_at))}
|
||||
{req.source ? ` · ${SOURCE_LABEL[req.source] || req.source}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{status ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">{status === 'accepted' ? '已通过' : '已拒绝'}</span>
|
||||
) : (
|
||||
<span className="flex flex-none items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acceptMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
通过验证
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface QRCodeModalProps {
|
||||
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
|
||||
* (README's "二维码名片" row on a friend's profile). */
|
||||
identifier?: string;
|
||||
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
|
||||
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
|
||||
groupTopic?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
|
||||
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
|
||||
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
|
||||
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
|
||||
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
|
||||
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
|
||||
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
|
||||
*/
|
||||
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
|
||||
useEscapeKey(onClose);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [drawError, setDrawError] = useState('');
|
||||
const query = useQuery<MyQRCode | GroupQRCode>({
|
||||
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
|
||||
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
|
||||
});
|
||||
|
||||
const draw = () => {
|
||||
if (!query.data || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
setDrawError('');
|
||||
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
|
||||
// isn't in every visitor's main bundle when most sessions never open this modal.
|
||||
import('qrcode')
|
||||
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
|
||||
.catch(() => setDrawError('二维码生成失败'));
|
||||
};
|
||||
|
||||
useEffect(draw, [query.data]);
|
||||
|
||||
const retry = () => {
|
||||
if (query.isError) query.refetch();
|
||||
else draw();
|
||||
};
|
||||
|
||||
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
|
||||
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
|
||||
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
|
||||
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
|
||||
|
||||
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
|
||||
|
||||
const saveToAlbum = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${fileBaseName}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const shareToFriend = async () => {
|
||||
const blob = await canvasToBlob();
|
||||
if (!blob) return;
|
||||
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
|
||||
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
|
||||
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
|
||||
// `canShare({ files })` is the one call that actually answers "can this share an image".
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
try {
|
||||
await navigator.share({ files: [file], title: fileBaseName });
|
||||
} catch {
|
||||
// AbortError (user cancelled the native share sheet) or any other failure -- either way
|
||||
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
|
||||
}
|
||||
} else {
|
||||
await saveToAlbum();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-7">
|
||||
{profile ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
|
||||
{avatarLetter(profile.display_name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
|
||||
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
|
||||
</>
|
||||
) : group ? (
|
||||
<>
|
||||
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
|
||||
{avatarLetter(group.name)}
|
||||
</span>
|
||||
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
|
||||
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
|
||||
{query.isLoading ? (
|
||||
<span className="text-sm text-text-tertiary-4">生成中…</span>
|
||||
) : query.isError || drawError ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
||||
<Icon name="error_outline" className="text-2xl text-danger-text" />
|
||||
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
|
||||
<button type="button" onClick={retry} className="text-xs text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<canvas ref={canvasRef} />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary-3">{caption}</span>
|
||||
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
|
||||
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
|
||||
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
|
||||
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
|
||||
{query.data && !drawError ? (
|
||||
<div className="mt-1 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveToAlbum}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="download" className="text-[16px]" />
|
||||
保存到相册
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareToFriend}
|
||||
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
|
||||
>
|
||||
<Icon name="share" className="text-[16px]" />
|
||||
分享好友
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { resolveQRCode, type QRResolveGroup, type QRResolveUser } from '@/lib/api/qrcodes';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface QRScannerModalProps {
|
||||
onClose: () => void;
|
||||
/** A resolved, genuinely-addable ('none' relationship, can_add) scanned user. */
|
||||
onResolved: (user: QRResolveUser) => void;
|
||||
/** Scanned code belongs to someone already a friend -- caller decides what "already added"
|
||||
* should do (e.g. navigate to their profile) instead of re-showing an add-friend form. */
|
||||
onAlreadyFriend: (user: QRResolveUser) => void;
|
||||
/** A `purpose:"join_group"` code resolved successfully and the scanning account is NOT already a
|
||||
* member -- optional so a caller that only ever scans for the add-friend flow (there is none
|
||||
* currently, but the type shouldn't force every caller to handle a case it can't reach) doesn't
|
||||
* have to wire a no-op. `qrToken` is threaded through because the join-request submission itself
|
||||
* needs to carry it (docs/IM-前后端对接文档.md:1591). */
|
||||
onGroupResolved?: (group: QRResolveGroup, qrToken: string) => void;
|
||||
/** Mirrors `onAlreadyFriend` for the group case -- confirmed live that resolving a code for a
|
||||
* group you're already in comes back `status:"joined"` rather than something requiring a join
|
||||
* request at all (see qrcodes.ts's QRResolveResult comment). */
|
||||
onAlreadyMember?: (group: QRResolveGroup) => void;
|
||||
}
|
||||
|
||||
const SCAN_INTERVAL_MS = 250;
|
||||
/** Old app's own real, working scanner (im_webapp/src/widgets/qr-scanner.jsx) downscales to this
|
||||
* max edge before decoding -- jsQR's cost scales with pixel count, and a raw 1080p+ camera frame
|
||||
* decoded every 250ms would be needlessly expensive when a QR code only needs a few hundred px
|
||||
* per side to read reliably. */
|
||||
const MAX_FRAME_EDGE = 480;
|
||||
|
||||
type ScanState = 'requesting' | 'scanning' | 'resolving' | 'camera-error' | 'resolve-error';
|
||||
|
||||
/**
|
||||
* README §5.4's "扫一扫" -- full-screen camera viewfinder. Ported cognition (not code) from the old
|
||||
* app's real, working `qr-scanner.jsx` (getUserMedia -> draw each frame to an offscreen canvas ->
|
||||
* jsQR) and `tinode-web.jsx`'s `handleQrDetected` (reject anything not matching the app's own
|
||||
* `im://qr?token=...&purpose=...` scheme client-side before ever sending it to `resolve`, so a
|
||||
* generic URL/WiFi/whatever QR code someone points this at is never even round-tripped).
|
||||
*/
|
||||
export function QRScannerModal({ onClose, onResolved, onAlreadyFriend, onGroupResolved, onAlreadyMember }: QRScannerModalProps) {
|
||||
useEscapeKey(onClose);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const resolvingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const [state, setState] = useState<ScanState>('requesting');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const stopCamera = () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDetected = async (text: string) => {
|
||||
if (resolvingRef.current) return;
|
||||
// Only this app's own generated codes resolve to anything -- a generic URL/WiFi/contact QR is
|
||||
// rejected client-side rather than round-tripped to the server for a guaranteed-useless lookup.
|
||||
if (!text.startsWith('im://qr?')) return;
|
||||
const params = new URLSearchParams(text.slice('im://qr?'.length));
|
||||
const token = params.get('token');
|
||||
const purpose = params.get('purpose') || 'add_friend';
|
||||
if (!token) return;
|
||||
resolvingRef.current = true;
|
||||
setState('resolving');
|
||||
try {
|
||||
const result = await resolveQRCode(token, purpose);
|
||||
// The modal may have been closed (or the user's intent otherwise moved on) while this request
|
||||
// was in flight -- applying its result now would redirect/populate state the user never asked
|
||||
// for after they believed they'd already cancelled.
|
||||
if (!mountedRef.current) return;
|
||||
if (purpose === 'join_group') {
|
||||
// See qrcodes.ts's own QRResolveGroup comment -- confirmed live this nests under `group`
|
||||
// (group_snapshot checked too as a defensive fallback, not because it's expected to fire).
|
||||
const group = result.group || result.group_snapshot;
|
||||
if (!group || !onGroupResolved) {
|
||||
// Same leak as the two other resolve-error branches below: without this, the scan loop and
|
||||
// camera stream both kept running underneath the full-screen error card, so the SAME
|
||||
// unsupported code re-triggered `resolveQRCode` every ~250ms indefinitely, and clicking
|
||||
// "重试" then started a second stream/interval on top without ever stopping this one.
|
||||
stopCamera();
|
||||
setErrorMessage('暂不支持此类二维码');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
return;
|
||||
}
|
||||
stopCamera();
|
||||
if (result.status === 'joined' && onAlreadyMember) onAlreadyMember(group);
|
||||
else onGroupResolved(group, token);
|
||||
return;
|
||||
}
|
||||
if (purpose !== 'add_friend' || !result.user) {
|
||||
stopCamera();
|
||||
setErrorMessage('暂不支持此类二维码');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
return;
|
||||
}
|
||||
stopCamera();
|
||||
if (result.user.relationship === 'friend') {
|
||||
onAlreadyFriend(result.user);
|
||||
} else if (!result.user.can_add) {
|
||||
setErrorMessage('无法添加该用户为好友');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
} else {
|
||||
onResolved(result.user);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
stopCamera();
|
||||
setErrorMessage(err instanceof Error ? err.message : '二维码已失效');
|
||||
setState('resolve-error');
|
||||
resolvingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-decoding library isn't
|
||||
// in every visitor's main bundle when most sessions never open the scanner.
|
||||
const startScanLoop = async () => {
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
if (!canvas || !video) return;
|
||||
const { default: jsQR } = await import('jsqr');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
timerRef.current = window.setInterval(() => {
|
||||
if (!ctx || video.readyState !== video.HAVE_ENOUGH_DATA || resolvingRef.current) return;
|
||||
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight));
|
||||
const w = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
const h = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
ctx.drawImage(video, 0, 0, w, h);
|
||||
const frame = ctx.getImageData(0, 0, w, h);
|
||||
const code = jsQR(frame.data, w, h, { inversionAttempts: 'dontInvert' });
|
||||
if (code?.data) handleDetected(code.data);
|
||||
}, SCAN_INTERVAL_MS);
|
||||
};
|
||||
|
||||
// React 18 StrictMode (dev only) runs mount -> cleanup -> mount synchronously, which starts TWO
|
||||
// overlapping getUserMedia() calls; without this generation token, whichever resolves last wins
|
||||
// and clobbers the other's streamRef/timerRef, leaking the loser's MediaStream (never .stop()'d)
|
||||
// and its setInterval. Matches the same-class guard already used in tinode/connection.ts/topics.ts.
|
||||
const requestGenerationRef = useRef(0);
|
||||
|
||||
const requestCamera = async () => {
|
||||
const myGeneration = ++requestGenerationRef.current;
|
||||
setState('requesting');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
|
||||
if (myGeneration !== requestGenerationRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
setState('scanning');
|
||||
startScanLoop();
|
||||
} catch (err) {
|
||||
if (myGeneration !== requestGenerationRef.current) return;
|
||||
setErrorMessage(err instanceof Error ? err.message : '无法访问摄像头');
|
||||
setState('camera-error');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
requestCamera();
|
||||
return stopCamera;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const retry = () => {
|
||||
resolvingRef.current = false;
|
||||
requestCamera();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-black">
|
||||
<header className="flex h-13 flex-none items-center justify-between px-4 text-white">
|
||||
<strong className="text-[15px] font-semibold">扫一扫</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="relative flex-1">
|
||||
<video ref={videoRef} playsInline muted className="h-full w-full object-cover" />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
{state === 'scanning' ? (
|
||||
<div className="absolute inset-0 grid place-items-center">
|
||||
<div className="h-56 w-56 rounded-2xl border-2 border-white/80" />
|
||||
</div>
|
||||
) : null}
|
||||
{state === 'requesting' || state === 'resolving' ? (
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/40 text-sm text-white">
|
||||
{state === 'requesting' ? '正在请求摄像头权限…' : '正在识别…'}
|
||||
</div>
|
||||
) : null}
|
||||
{state === 'camera-error' || state === 'resolve-error' ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/70 px-8 text-center text-white">
|
||||
<Icon name={state === 'camera-error' ? 'videocam_off' : 'error_outline'} className="text-4xl" />
|
||||
<span className="text-sm">{state === 'camera-error' ? `无法访问摄像头:${errorMessage}` : errorMessage}</span>
|
||||
<button type="button" onClick={retry} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="flex-none py-4 text-center text-sm text-white/70">将二维码放入框内,即可自动扫描</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import {
|
||||
batchDeleteFavorites,
|
||||
deleteFavorite,
|
||||
favoriteEntityData,
|
||||
forwardFavorites,
|
||||
getFavorites,
|
||||
type Favorite,
|
||||
type FavoriteType,
|
||||
} from '@/lib/api/favorites';
|
||||
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: 'all_inbox' },
|
||||
{ key: 'text', label: '文本', icon: 'notes' },
|
||||
{ key: 'image', label: '图片', icon: 'image' },
|
||||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||||
{ key: 'voice', label: '语音', icon: 'mic' },
|
||||
{ key: 'file', label: '文件', icon: 'description' },
|
||||
{ key: 'link', label: '链接', icon: 'link' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the
|
||||
* confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so
|
||||
* this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup
|
||||
* ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name.
|
||||
*
|
||||
* Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`-
|
||||
* hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and
|
||||
* every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()`
|
||||
* doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this
|
||||
* is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side
|
||||
* way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never
|
||||
* match it -- showing the raw hash string would be confusing, so this falls back to a plain label
|
||||
* for that case instead of guessing.
|
||||
*/
|
||||
function SourceLine({ source }: { source: Favorite['source'] }) {
|
||||
const contacts = useChatList();
|
||||
if (!source) return null;
|
||||
const known = contacts.find((c) => c.topic === source.topic)?.public?.fn;
|
||||
// Same reasoning as the p2p fallback below -- a group this account has never actually opened as a
|
||||
// real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local
|
||||
// chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads
|
||||
// as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label.
|
||||
const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic;
|
||||
return <span className="truncate">{known || fallback}</span>;
|
||||
}
|
||||
|
||||
function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) {
|
||||
const removed = item.availability !== 'available';
|
||||
|
||||
const body = (() => {
|
||||
if (removed) return <div className="text-sm text-text-tertiary-4">该内容已失效或被删除</div>;
|
||||
switch (item.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<p className="m-0 line-clamp-[5] max-h-[88px] overflow-hidden text-sm leading-[1.6] text-text-body">
|
||||
{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}
|
||||
</p>
|
||||
);
|
||||
case 'image':
|
||||
case 'video': {
|
||||
const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM');
|
||||
// Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an <img> --
|
||||
// the paintable poster frame is the separate inline `data.preview` (confirmed live: base64
|
||||
// JPEG signature); image has no separate poster since the image itself IS the thumbnail.
|
||||
const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined;
|
||||
const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined;
|
||||
return (
|
||||
<MediaPreview
|
||||
inlineVal={inlineVal}
|
||||
inlineMime={inlineMime}
|
||||
fileId={item.snapshot.thumbnail_file_id || item.snapshot.file_id}
|
||||
isVideo={item.type === 'video'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'voice': {
|
||||
// Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) --
|
||||
// this card used to be static (a bare play_circle icon with no click handler at all), so a
|
||||
// favorited voice message could never actually be played from this view.
|
||||
const data = favoriteEntityData(item.snapshot.content, 'AU');
|
||||
return (
|
||||
// Stops the play/pause click from also bubbling up to the card's own onClick -- in
|
||||
// selectMode that outer handler toggles selection, and without this, tapping play would
|
||||
// simultaneously start/stop audio AND select/deselect the card.
|
||||
<div className="rounded-lg bg-surface-rail px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
|
||||
<AudioAttachment
|
||||
data={{
|
||||
val: typeof data?.val === 'string' ? data.val : undefined,
|
||||
ref: typeof data?.ref === 'string' ? data.ref : item.snapshot.file_id ? `/v0/file/s/${item.snapshot.file_id}` : undefined,
|
||||
mime: typeof data?.mime === 'string' ? data.mime : undefined,
|
||||
duration: typeof data?.duration === 'number' ? data.duration : item.snapshot.duration_ms,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'file': {
|
||||
const data = favoriteEntityData(item.snapshot.content, 'EX');
|
||||
const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || '';
|
||||
const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0;
|
||||
const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime;
|
||||
const style = fileTypeStyle(name, mime);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
<span className="text-xs text-text-tertiary-2">{formatFileSize(size)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'link': {
|
||||
// Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own
|
||||
// text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail
|
||||
// data of its own the way a standalone attachment entity does. So a "link" favorite's real
|
||||
// snapshot is most likely just the SAME whole-message wrapper every other type turned out to
|
||||
// be (Part D's finding), meaning its own top-level `text` field (identical source the 'text'
|
||||
// case above already reads) is the one thing actually guaranteed to be there -- the flat
|
||||
// `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data
|
||||
// and are kept only as an optional enhancement layer, not the sole source. Without this
|
||||
// fallback, a real link favorite whose backend response doesn't happen to populate those flat
|
||||
// fields rendered a card with a completely empty title line.
|
||||
const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '');
|
||||
const title = item.snapshot.title || item.snapshot.url || text;
|
||||
// Only show a second line when it says something the title line doesn't already -- a
|
||||
// hostname alongside a real title/url is useful, but repeating `text` under itself when
|
||||
// that's ALSO what the title line fell back to would just be the same line twice.
|
||||
// `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from
|
||||
// another client's own Drafty implementation or the backend's own extraction logic (docs/
|
||||
// IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single
|
||||
// malformed value here must not crash the whole render (there's no error boundary around this
|
||||
// list short of react-router's own default one, which would replace the entire logged-in UI).
|
||||
let subtitle = title === text ? '' : text;
|
||||
if (item.snapshot.url) {
|
||||
try {
|
||||
subtitle = new URL(item.snapshot.url).hostname;
|
||||
} catch {
|
||||
// fall through to the plain-text fallback computed above
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
{item.snapshot.thumbnail_file_id ? (
|
||||
<span className="h-14 w-14 flex-none overflow-hidden rounded-lg bg-surface-rail">
|
||||
<MediaPreview fileId={item.snapshot.thumbnail_file_id} isVideo={false} square />
|
||||
</span>
|
||||
) : (
|
||||
<span className="grid h-14 w-14 flex-none place-items-center rounded-lg bg-surface-rail">
|
||||
<Icon name="link" className="text-[20px] text-text-tertiary-3" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{title}</span>
|
||||
{subtitle ? <span className="truncate text-xs text-text-tertiary-2">{subtitle}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
// A plain <div> (not <button>) -- the voice card nests AudioAttachment's own real <button> inside
|
||||
// this, and a <button> can't legally contain another <button> (React logs a hydration-nesting
|
||||
// error for it, confirmed live). role/tabIndex/onKeyDown restore button semantics only while this
|
||||
// is actually clickable (selectMode); outside selectMode it's inert, same as before.
|
||||
return (
|
||||
<div
|
||||
role={selectMode ? 'button' : undefined}
|
||||
tabIndex={selectMode ? 0 : undefined}
|
||||
onClick={selectMode ? onToggle : undefined}
|
||||
onKeyDown={
|
||||
selectMode
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`flex flex-col gap-3 rounded-xl border-2 bg-white p-4 text-left ${selected ? 'border-accent' : 'border-transparent'}`}
|
||||
>
|
||||
<div className="relative">
|
||||
{body}
|
||||
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">
|
||||
<SourceLine source={item.source} />
|
||||
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaPreview({
|
||||
fileId,
|
||||
inlineVal,
|
||||
inlineMime,
|
||||
isVideo,
|
||||
square,
|
||||
}: {
|
||||
fileId?: string;
|
||||
inlineVal?: string;
|
||||
inlineMime?: string;
|
||||
isVideo: boolean;
|
||||
square?: boolean;
|
||||
}) {
|
||||
// Real favorites carry the thumbnail inline as base64 (see favorites.ts's `Favorite` doc comment);
|
||||
// `fileId` is kept as a fallback for a hypothetical external-ref-only variant, never observed live.
|
||||
const inline = inlineVal ? `data:${inlineMime || 'image/jpeg'};base64,${inlineVal}` : undefined;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : fileId ? `/v0/file/s/${fileId}` : undefined);
|
||||
const src = inline || remote.url;
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-lg bg-surface-rail ${square ? 'h-14 w-14' : 'h-[120px] w-full'}`}>
|
||||
{src ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{isVideo && src ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* README §6.1 (3-column: Nav / 分类 260px / 内容). `favoriteMessages` (the create side, called from
|
||||
* a message's context menu) lives in messages.ts; this is the read/manage side, new in this pass.
|
||||
*/
|
||||
export function FavoritesLayout() {
|
||||
const queryClient = useQueryClient();
|
||||
const [category, setCategory] = useState<FavoriteType | 'all'>('all');
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [forwarding, setForwarding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const favoritesQuery = useInfiniteQuery({
|
||||
queryKey: ['favorites', category, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => getFavorites(category, debouncedQuery, pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = favoritesQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const invalidateAll = () => queryClient.invalidateQueries({ queryKey: ['favorites'] });
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
// Both API shapes are declared with an optional `failed[]` (favorites.ts:92) -- a 200 here only
|
||||
// means the request was accepted, not that every selected item was actually deleted. Returning
|
||||
// the real response (was previously discarded entirely) so onSuccess can check it, same pattern
|
||||
// already fixed for BlocklistView's batch-unblock/GroupAdminModal elsewhere in this app.
|
||||
mutationFn: async (ids: string[]) => {
|
||||
if (ids.length === 1) {
|
||||
await deleteFavorite(ids[0]);
|
||||
return null;
|
||||
}
|
||||
return batchDeleteFavorites(ids);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
invalidateAll();
|
||||
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
|
||||
// the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying
|
||||
// meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead,
|
||||
// primed for an immediate retry.
|
||||
if (data?.failed?.length) {
|
||||
setSelected(new Set(data.failed.map((f) => f.favorite_id)));
|
||||
setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
} else {
|
||||
exitSelectMode();
|
||||
}
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
const forwardMutation = useMutation({
|
||||
// forwardFavorites already accepts multiple target_topics + a comment natively -- this used to
|
||||
// wrap a single topic in a 1-element array, silently discarding the picker's ability to select
|
||||
// several recipients or attach a comment.
|
||||
mutationFn: ({ targetTopics, comment }: { targetTopics: string[]; comment: string }) =>
|
||||
forwardFavorites([...selected], targetTopics, comment),
|
||||
onSuccess: (data) => {
|
||||
setForwarding(false);
|
||||
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
|
||||
// succeed for 2 and fail for 1, and silently reporting "success" either way (including
|
||||
// clearing the favorites selection) hid that AND left nothing to retry from. The picker
|
||||
// itself still closes (which of several targets failed isn't tracked here) but the source
|
||||
// favorites stay selected so re-opening "转发" immediately can retry against the same set.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||
else exitSelectMode();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
|
||||
});
|
||||
|
||||
// Switching category mid-multi-select used to leave the OLD category's selections in `selected`
|
||||
// silently intact (nothing in this state ever cleared them) -- the header's "已选择 N 项" count and
|
||||
// any 转发/删除 action would then silently include items the user can no longer even see. Category
|
||||
// switches always start a fresh selection instead.
|
||||
const switchCategory = (key: FavoriteType | 'all') => {
|
||||
setCategory(key);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
// Same reasoning as switchCategory above, for the OTHER thing that changes which items are even
|
||||
// visible -- typing a search query re-filters the list just like picking a category does, and had
|
||||
// the identical gap (nothing here ever cleared `selected` when the query changed).
|
||||
const handleQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelected(new Set());
|
||||
};
|
||||
const bulkDelete = async () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return;
|
||||
deleteMutation.mutate([...selected]);
|
||||
};
|
||||
|
||||
const categoryPicker = (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => switchCategory(c.key)}
|
||||
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
|
||||
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1'
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const searchBox = (
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => handleQueryChange(e.target.value)}
|
||||
placeholder="搜索收藏内容"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md`
|
||||
there's no room to dock a 260px rail next to content at all -- screens/05-favorites-
|
||||
history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a
|
||||
horizontal pill category-switcher inline above the list, not a separate "pick a category"
|
||||
screen you navigate away from. This aside is desktop-only; its mobile equivalent is the
|
||||
pill row rendered inline in the content column below. */}
|
||||
<aside className="hidden h-full w-[260px] flex-none flex-col border-r border-border-panel bg-white md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">我的收藏</h1>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5">{searchBox}</div>
|
||||
<div className="flex flex-col gap-0.5 px-3">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
onClick={() => switchCategory(c.key)}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm ${
|
||||
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={c.icon} className={`text-[18px] ${category === c.key ? '' : 'text-text-tertiary-4'}`} />
|
||||
<span className="flex-1 text-left">{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col bg-surface-content">
|
||||
{selectMode ? (
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-4 md:px-6">
|
||||
<span className="text-sm text-text-secondary">
|
||||
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForwarding(true)}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
|
||||
>
|
||||
<Icon name="reply" className="text-[18px]" />
|
||||
转发
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkDelete}
|
||||
disabled={selected.size === 0}
|
||||
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-danger-border-soft px-3.5 text-[13px] text-danger-text disabled:opacity-40"
|
||||
>
|
||||
<Icon name="delete_outline" className="text-[18px]" />
|
||||
删除
|
||||
</button>
|
||||
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
|
||||
取消
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via
|
||||
设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every
|
||||
other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
|
||||
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
|
||||
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[18px]" />
|
||||
</Link>
|
||||
<strong className="truncate text-center text-base font-semibold text-text-heading">我的收藏</strong>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</header>
|
||||
<div className="flex-none border-b border-border-panel pt-3 md:hidden">
|
||||
<div className="px-4">{searchBox}</div>
|
||||
{categoryPicker}
|
||||
</div>
|
||||
|
||||
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6 md:flex">
|
||||
<strong className="text-base font-semibold text-text-heading">{CATEGORIES.find((c) => c.key === category)?.label}</strong>
|
||||
{items.length ? (
|
||||
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
|
||||
多选
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{favoritesQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : favoritesQuery.isError ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-danger-text">
|
||||
<Icon name="error_outline" className="!text-4xl" />
|
||||
<span className="text-sm">加载失败,请重试</span>
|
||||
<button type="button" onClick={() => favoritesQuery.refetch()} className="text-sm text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-20 text-text-tertiary-4">
|
||||
<Icon name="inventory_2" className="!text-4xl" />
|
||||
<span className="text-sm">暂无收藏内容</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly),
|
||||
3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own
|
||||
judgment call, not in either mock, for the range where 1 column would waste width
|
||||
and 3 would be as cramped as the bug this replaces. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 md:gap-4">
|
||||
{items.map((item) => (
|
||||
<FavoriteCard
|
||||
key={item.favorite_id}
|
||||
item={item}
|
||||
selectMode={selectMode}
|
||||
selected={selected.has(item.favorite_id)}
|
||||
onToggle={() => toggle(item.favorite_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{favoritesQuery.hasNextPage ? (
|
||||
<div className="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => favoritesQuery.fetchNextPage()}
|
||||
disabled={favoritesQuery.isFetchingNextPage}
|
||||
className="text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{favoritesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{forwarding ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwarding(false)}
|
||||
onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })}
|
||||
/>
|
||||
) : null}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
interface CreateGroupModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: (topic: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.7 (680x520): letter-grouped friend picker on the left, selected chips + group-name
|
||||
* input on the right. Grouped by the first character of the display name -- a true pinyin-initial
|
||||
* grouping (matching the reference's A/B/C-style index) would need a pinyin library this codebase
|
||||
* doesn't have anywhere else; first-character buckets are a reasonable stand-in and already correct
|
||||
* for the plain-ASCII usernames this backend's test accounts actually have.
|
||||
*/
|
||||
export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, string>>(new Map());
|
||||
const [name, setName] = useState('');
|
||||
const [joinApproval, setJoinApproval] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const friends = friendsQuery.data || [];
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = friends.filter((f) => !trimmed || (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmed));
|
||||
const byLetter = new Map<string, typeof friends>();
|
||||
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || a.user_id).localeCompare(b.remark || b.display_name || b.user_id))) {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
const letter = /[A-Za-z]/.test(label[0] || '') ? label[0].toUpperCase() : '#';
|
||||
if (!byLetter.has(letter)) byLetter.set(letter, []);
|
||||
byLetter.get(letter)!.push(f);
|
||||
}
|
||||
return [...byLetter.entries()].sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [friends, query]);
|
||||
|
||||
const toggle = (userId: string, label: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(userId)) next.delete(userId);
|
||||
else next.set(userId, label);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (selected.size === 0 || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
let result: Awaited<ReturnType<typeof createGroup>>;
|
||||
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination
|
||||
// of member nicknames when left blank), but `name` has no such fallback server-side (unlike
|
||||
// `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a
|
||||
// blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared
|
||||
// outside the try block since the catch below needs it too.
|
||||
const usedAutoName = !name.trim();
|
||||
try {
|
||||
// Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
|
||||
// it had to cut names short, so this can't itself produce an over-length name the server would
|
||||
// also reject.
|
||||
const trimmedName = name.trim();
|
||||
let finalName = trimmedName;
|
||||
if (!finalName) {
|
||||
const joined = [...selected.values()].join('、');
|
||||
finalName = joined.length > MAX_TITLE_LENGTH ? `${joined.slice(0, MAX_TITLE_LENGTH - 1)}…` : joined;
|
||||
}
|
||||
result = await createGroup({
|
||||
name: finalName,
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
// Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the
|
||||
// "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content
|
||||
// moderation flagging a purely-numeric nickname combination), and the generic error message
|
||||
// gives someone who left the field blank no clue that "the name" is even what's wrong, let
|
||||
// alone that they can fix it by typing one in themselves.
|
||||
if (usedAutoName) {
|
||||
setError('自动生成的群名未通过审核,请手动输入一个群名后重试');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : '创建群聊失败');
|
||||
}
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
// The group now genuinely exists -- from here on, a failure is NOT "创建群聊失败" (which would
|
||||
// read as "nothing happened, try again" and could prompt a duplicate re-submit). Kept in its own
|
||||
// try/catch so the 进群验证 follow-up call failing can't get blamed on group creation itself.
|
||||
if (joinApproval) {
|
||||
try {
|
||||
// README §4.5's 进群验证 governance toggle defaults off; a group created with "开启进群验证"
|
||||
// checked needs it turned on right after creation since createGroup's own body has no field
|
||||
// for it (docs/IM-前后端对接文档.md:1445's create body is name/description/member_user_ids/
|
||||
// avatar_file_id only -- governance is a separate resource).
|
||||
const gov = await getGroupGovernance(result.topic);
|
||||
await updateGroupGovernance(result.topic, { join_approval: true, version: gov.version });
|
||||
} catch {
|
||||
// Non-fatal: the group is real and usable, just without 进群验证 turned on yet -- that's a
|
||||
// setting the owner can still flip from 群管理 afterward, not a reason to hide a real group.
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
onCreated(result.topic);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[85vh] w-full flex-col rounded-xl bg-white shadow-modal md:h-[520px] md:w-[680px]" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">创建群聊</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col overflow-hidden md:flex-row">
|
||||
<div className="flex w-full flex-1 flex-col border-b border-border-list-item md:w-[340px] md:flex-none md:border-b-0 md:border-r">
|
||||
<div className="flex-none p-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索联系人"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
||||
{friendsQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">未找到联系人</div>
|
||||
) : (
|
||||
groups.map(([letter, items]) => (
|
||||
<div key={letter}>
|
||||
<div className="px-2 pb-1 pt-2 text-xs text-text-tertiary-3">{letter}</div>
|
||||
{items.map((f) => {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
const checked = selected.has(f.user_id);
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
onClick={() => toggle(f.user_id, label)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<SelectionCheckbox selected={checked} />
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
|
||||
>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="flex-none text-sm text-text-secondary">已选 {selected.size} 人</div>
|
||||
<div className="mt-2 flex flex-wrap content-start gap-2 overflow-y-auto" style={{ maxHeight: 220 }}>
|
||||
{[...selected.entries()].map(([userId, label]) => (
|
||||
<span key={userId} className="flex items-center gap-1 rounded-2xl bg-surface-rail py-1 pl-1 pr-2">
|
||||
<span className={`grid h-[22px] w-[22px] place-items-center rounded-full text-[11px] text-white ${avatarBgClass(userId)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="max-w-[100px] truncate text-xs text-text-body">{label}</span>
|
||||
<button type="button" onClick={() => toggle(userId, label)} className="grid h-4 w-4 place-items-center text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[14px]" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||||
placeholder="未填写时默认为成员昵称组合"
|
||||
className="h-10 rounded-lg bg-surface-input px-3 text-sm outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={joinApproval} onChange={(e) => setJoinApproval(e.target.checked)} className="accent-accent" />
|
||||
开启进群验证
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={selected.size === 0 || submitting}
|
||||
className="h-10 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '创建中…' : `创建群聊 (${selected.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||
import { privateChatOption, privateChatOptions } from '@/features/groups/group-settings';
|
||||
import {
|
||||
addGroupAdmins,
|
||||
getAllGroupMembers,
|
||||
getGroup,
|
||||
getGroupGovernance,
|
||||
getAllGroupMutes,
|
||||
getGroupRoleLimits,
|
||||
getGroupSummary,
|
||||
muteGroupMembers,
|
||||
removeGroupAdmin,
|
||||
transferGroupOwner,
|
||||
unmuteGroupMember,
|
||||
updateGroupGovernance,
|
||||
type GroupMember,
|
||||
type GroupMute,
|
||||
} from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { requestActionToken } from '@/stores/securityChallenge';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface GroupAdminModalProps {
|
||||
topicId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`px-3 py-1 disabled:opacity-50 ${value === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type SubView = 'main' | 'admins' | 'mutes' | 'join-requests' | 'transfer';
|
||||
|
||||
/** README §4.5 (560px): governance toggles/segments + admins/mutes/join-requests/owner-transfer link
|
||||
* rows. `addGroupAdmins`/`removeGroupAdmin`/`transferGroupOwner` all share ONE optimistic-lock
|
||||
* counter with the group profile itself -- confirmed by the doc's own worked example (§6.29):
|
||||
* admins/batch takes version:1 and returns version:2, then owner-transfer takes version:2 and
|
||||
* returns version:3. So the current value always comes from `getGroup`, re-read (via query
|
||||
* invalidation) after every such mutation -- a hardcoded constant would only be correct once. */
|
||||
export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [view, setView] = useState<SubView>('main');
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
|
||||
// README §4.5: 转让群主仅群主可见 -- `GroupPermissions` has no dedicated `can_transfer_ownership`
|
||||
// field, but `GroupProfile.owner` (the real owner's user id) is already fetched here for the
|
||||
// shared version counter, so comparing it against the session's own id is free, no extra request.
|
||||
const isOwner = !!myUserId && groupQuery.data?.owner === myUserId;
|
||||
const govQuery = useQuery({ queryKey: ['group-governance', topicId], queryFn: () => getGroupGovernance(topicId) });
|
||||
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
|
||||
const roleLimitsQuery = useQuery({ queryKey: ['group-role-limits', topicId], queryFn: () => getGroupRoleLimits(topicId) });
|
||||
// Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupAdmins): the entire
|
||||
// `/admins*` surface -- list included, not just add/remove -- requires OWNER, a plain admin gets
|
||||
// 403 even just listing. `enabled: isOwner` stops a non-owner admin from firing a request that can
|
||||
// only ever fail, and pairs with the `isOwner` gate on the "群管理员" row itself below.
|
||||
const adminsQuery = useQuery({
|
||||
queryKey: ['group-members', topicId, 'admin'],
|
||||
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
|
||||
enabled: isOwner,
|
||||
});
|
||||
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getAllGroupMutes(topicId) });
|
||||
|
||||
const govMutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
|
||||
onSuccess: (data) => queryClient.setQueryData(['group-governance', topicId], data),
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
|
||||
});
|
||||
const setGov = (
|
||||
patch: Partial<{ join_approval: boolean; mute_all: boolean; member_upload_files: boolean; member_private_chat: boolean; member_invite: boolean }>,
|
||||
) => {
|
||||
if (!govQuery.data) return;
|
||||
govMutation.mutate({ ...patch, version: govQuery.data.version });
|
||||
};
|
||||
|
||||
const maxAdminCount = roleLimitsQuery.data?.max_admin_count;
|
||||
|
||||
if (view === 'join-requests') return <JoinRequestsModal topicId={topicId} onClose={() => setView('main')} />;
|
||||
if (view === 'admins')
|
||||
return (
|
||||
<AdminsSubView
|
||||
topicId={topicId}
|
||||
admins={adminsQuery.data || []}
|
||||
maxAdminCount={maxAdminCount}
|
||||
groupVersion={groupQuery.data?.version}
|
||||
onClose={() => setView('main')}
|
||||
/>
|
||||
);
|
||||
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data || []} onClose={() => setView('main')} />;
|
||||
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[80vh] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群管理</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-2 text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">进群验证</span>
|
||||
<span className="text-xs text-text-tertiary-3">开启后新成员需管理员审核</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.join_approval}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.join_approval_editable === false}
|
||||
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">全员禁言</span>
|
||||
<span className="text-xs text-text-tertiary-3">仅群主和管理员可以发言</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={!!govQuery.data?.mute_all}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.mute_all_editable === false}
|
||||
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">群文件上传权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_upload_files ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_upload_files_editable === false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">成员私聊权限</span>
|
||||
<SegmentedControl
|
||||
value={privateChatOption(!!govQuery.data?.member_private_chat)}
|
||||
options={privateChatOptions}
|
||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
|
||||
/>
|
||||
</div>
|
||||
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
|
||||
real, already-read/write-capable field with no row anywhere in 03-group.dc.html's own
|
||||
群管理弹层 mock (#1b only draws 4 governance rows) -- placed here as a 5th row using the
|
||||
SAME SegmentedControl treatment as 群文件上传权限/成员私聊权限 just above (same modal,
|
||||
same "who is allowed to do X" semantics), rather than inventing a new visual pattern. */}
|
||||
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
|
||||
<span className="text-text-body">邀请入群权限</span>
|
||||
<SegmentedControl
|
||||
value={govQuery.data?.member_invite ? 'all' : 'admin'}
|
||||
options={[
|
||||
{ value: 'all', label: '所有成员' },
|
||||
{ value: 'admin', label: '仅管理员' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_invite: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
|
||||
/>
|
||||
</div>
|
||||
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
|
||||
entire sub-resource (list included) requires ownership, not just admin. */}
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('admins')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">群管理员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">
|
||||
{adminsQuery.data?.length ?? 0}
|
||||
{maxAdminCount ? `/${maxAdminCount}` : ''}
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">禁言成员</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
<span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setView('join-requests')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
|
||||
<span className="text-text-body">进群申请</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{summaryQuery.data?.pending_join_request_count ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{summaryQuery.data.pending_join_request_count}
|
||||
</b>
|
||||
) : null}
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
{isOwner ? (
|
||||
<button type="button" onClick={() => setView('transfer')} className="flex w-full items-center justify-between py-3.5 text-left">
|
||||
<span className="text-text-body">转让群主</span>
|
||||
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared candidate list for the admin/mute/owner-transfer pickers. All three server endpoints
|
||||
* follow the same "候选" shape (doc §6.19/6.23/6.25): `GET .../members` with an `eligible_role=` or
|
||||
* `mute_eligible=` filter, whose response items still carry `eligible` for entries the caller can't
|
||||
* act on -- so this filters those out client-side rather than trusting the query param alone to
|
||||
* have excluded them server-side. */
|
||||
function MemberPickerList({
|
||||
topicId,
|
||||
cacheKey,
|
||||
opts,
|
||||
onPick,
|
||||
emptyLabel,
|
||||
disabled,
|
||||
}: {
|
||||
topicId: string;
|
||||
cacheKey: string;
|
||||
opts: Parameters<typeof getAllGroupMembers>[1];
|
||||
onPick: (m: GroupMember) => void;
|
||||
emptyLabel: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId, cacheKey], queryFn: () => getAllGroupMembers(topicId, opts) });
|
||||
const candidates = (membersQuery.data || []).filter((m) => m.eligible !== false);
|
||||
if (membersQuery.isLoading) return <div className="py-6 text-center text-sm text-text-tertiary-4">加载中…</div>;
|
||||
if (candidates.length === 0) return <div className="py-6 text-center text-sm text-text-tertiary-4">{emptyLabel}</div>;
|
||||
return (
|
||||
<>
|
||||
{candidates.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick(m)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(groupMemberDisplayName(m))}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center gap-2 border-b border-border-list-item px-3">
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 flex-none place-items-center text-text-tertiary-4">
|
||||
<Icon name="arrow_back" />
|
||||
</button>
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminsSubView({
|
||||
topicId,
|
||||
admins,
|
||||
maxAdminCount,
|
||||
groupVersion,
|
||||
onClose,
|
||||
}: {
|
||||
topicId: string;
|
||||
admins: GroupMember[];
|
||||
maxAdminCount: number | undefined;
|
||||
groupVersion: number | undefined;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
// `group-members-preview` is a SEPARATE cache key (GroupDetailColumn.tsx's own 9-grid query) --
|
||||
// missing it here meant promoting/demoting an admin never refreshed that grid's own "主"/"管"
|
||||
// corner badges until something else happened to invalidate it.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-role-limits', topicId] });
|
||||
};
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (userId: string) => addGroupAdmins(topicId, [userId], groupVersion ?? 1),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAdding(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '添加失败'),
|
||||
});
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupAdmin(topicId, userId, groupVersion ?? 1),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移除失败'),
|
||||
});
|
||||
|
||||
const countLabel = maxAdminCount ? `${admins.length}/${maxAdminCount}` : `${admins.length}`;
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title={adding ? '添加管理员' : `群管理员 ${countLabel}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-admin"
|
||||
opts={{ eligibleRole: 'admin' }}
|
||||
onPick={(m) => addMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可添加的成员"
|
||||
disabled={addMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{admins.map((a) => (
|
||||
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
|
||||
{avatarLetter(groupMemberDisplayName(a))}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(a)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(a.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="text-xs text-danger-text disabled:opacity-50"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const atLimit = !!maxAdminCount && admins.length >= maxAdminCount;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
disabled={atLimit}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent disabled:text-text-tertiary-3"
|
||||
>
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
{atLimit ? '已达管理员人数上限' : '添加管理员'}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
// `GroupMute` itself carries only a bare `user_id` (see its own type comment) -- resolve a real
|
||||
// name/avatar the same way ConversationView.tsx's `memberByUserId` does, rather than rendering the
|
||||
// raw id (this list previously crashed outright reading a `.user.display_name` that never existed
|
||||
// on the real response, confirmed live: `Cannot read properties of undefined (reading 'user_id')`
|
||||
// the moment a real muted member was ever rendered here).
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const memberByUserId = new Map((membersQuery.data || []).map((m) => [m.user_id, m]));
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
|
||||
const muteMutation = useMutation({
|
||||
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
|
||||
onSuccess: () => {
|
||||
invalidate();
|
||||
setAdding(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '禁言失败'),
|
||||
});
|
||||
const unmuteMutation = useMutation({
|
||||
mutationFn: (userId: string) => unmuteGroupMember(topicId, userId),
|
||||
onSuccess: invalidate,
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '解除禁言失败'),
|
||||
});
|
||||
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title={adding ? '选择禁言成员' : `禁言成员 ${muted.length}`} onClose={adding ? () => setAdding(false) : onClose}>
|
||||
{adding ? (
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="mute-eligible"
|
||||
opts={{ muteEligible: true }}
|
||||
onPick={(m) => muteMutation.mutate(m.user_id)}
|
||||
emptyLabel="没有可禁言的成员"
|
||||
disabled={muteMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{muted.map((m) => {
|
||||
const member = memberByUserId.get(m.user_id);
|
||||
const name = member ? groupMemberDisplayName(member) : m.user_id;
|
||||
return (
|
||||
<div key={m.user_id} className="flex items-center gap-3 px-3 py-2">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmuteMutation.mutate(m.user_id)}
|
||||
disabled={unmuteMutation.isPending}
|
||||
className="text-xs text-accent disabled:opacity-50"
|
||||
>
|
||||
解除禁言
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
禁言成员
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: string; groupVersion: number | undefined; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const transferMutation = useMutation({
|
||||
mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
|
||||
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
// Same gap as AdminsSubView's own invalidate() -- the 9-grid "主" badge in GroupDetailColumn
|
||||
// reads this separate cache key, not `group-members`.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '转让群主失败'),
|
||||
});
|
||||
|
||||
useEscapeKey(onClose);
|
||||
return (
|
||||
<SubModalShell title="转让群主" onClose={onClose}>
|
||||
<MemberPickerList
|
||||
topicId={topicId}
|
||||
cacheKey="eligible-owner"
|
||||
opts={{ eligibleRole: 'owner' }}
|
||||
onPick={async (m) => {
|
||||
if (!(await requestConfirm({ message: `确定将群主转让给 ${groupMemberDisplayName(m)} 吗?此操作不可撤销。`, danger: true }))) return;
|
||||
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
|
||||
if (!actionToken) return;
|
||||
transferMutation.mutate({ userId: m.user_id, actionToken });
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
/>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
|
||||
interface GroupAnnouncementModalProps {
|
||||
topicId: string;
|
||||
canEdit: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.8 (480px): publisher avatar/name/role tag + publish time, body text, read count, and an
|
||||
* 编辑 affordance for owner/admin. `PUT .../announcements/current` is optimistic-locked by
|
||||
* `version`, confirmed present in the GET response (doc line 1745, and re-confirmed live). Two
|
||||
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
|
||||
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
|
||||
* -- resolved here against the group's member list, same as any other bare user id in this API.
|
||||
* When nothing has been published yet, the endpoint still returns `200` with every field
|
||||
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
|
||||
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
|
||||
* "announcement" -- empty name, empty date -- instead of the empty state).
|
||||
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
|
||||
* added them (see groups.ts's own comment on `GroupAnnouncement`), so this now renders "已读N/M" as
|
||||
* README §4.8 originally called for, and calls `readGroupAnnouncement` on open -- that REST endpoint
|
||||
* existed in groups.ts since before this fix but was never actually called from anywhere, so opening
|
||||
* an announcement never told the server "this member has read it" at all.
|
||||
*/
|
||||
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const query = useQuery({ queryKey: ['group-announcement', topicId], queryFn: () => getLatestGroupAnnouncement(topicId) });
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
|
||||
|
||||
// Fire-and-forget: viewing the announcement IS "reading" it, matching how the P2P/group message
|
||||
// read receipt (native `{note what:"read"}`) already works elsewhere in this app -- no explicit
|
||||
// "mark as read" button anywhere in the mock. Guarded on a real, non-empty `announcement_id` so
|
||||
// this doesn't fire for the zero-valued "nothing published yet" response, and keyed on that id (not
|
||||
// just "modal opened") so re-opening the SAME announcement doesn't re-send needlessly, though the
|
||||
// endpoint's own idempotency key means it would be harmless either way.
|
||||
const announcementId = query.data?.announcement_id;
|
||||
useEffect(() => {
|
||||
if (!announcementId) return;
|
||||
// Invalidate (not just fire-and-forget) so THIS viewer's own read immediately bumps the
|
||||
// displayed count instead of showing a stale pre-read number until some unrelated refetch.
|
||||
readGroupAnnouncement(topicId, announcementId)
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['group-announcement', topicId] }))
|
||||
.catch(() => {});
|
||||
}, [topicId, announcementId, queryClient]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['group-announcement', topicId], data);
|
||||
// `GET .../settings` genuinely has no `announcement` field (see GroupDetailColumn.tsx's own
|
||||
// comment on this) -- the detail rail's own announcement preview actually reads
|
||||
// `group-summary`, so invalidating `group-settings` here never refreshed it; the preview text
|
||||
// was stuck at whatever it was before this save until something ELSE happened to touch summary.
|
||||
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
|
||||
setEditing(false);
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '发布失败'),
|
||||
});
|
||||
|
||||
const startEdit = () => {
|
||||
setDraft(query.data?.content || '');
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex max-h-[70vh] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群公告</strong>
|
||||
<div className="flex items-center gap-3">
|
||||
{canEdit && !editing ? (
|
||||
<button type="button" onClick={startEdit} className="text-sm text-accent">
|
||||
{query.data?.content ? '编辑' : '发布'}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{editing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={8}
|
||||
placeholder="发布群公告,所有成员都会收到通知"
|
||||
className="resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft.trim())}
|
||||
disabled={!draft.trim() || saveMutation.isPending}
|
||||
className="h-10 self-end rounded-lg bg-btn px-5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{saveMutation.isPending ? '发布中…' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
) : query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !query.data?.content ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群公告</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(query.data.author)}`}>
|
||||
{avatarLetter(author?.display_name || query.data.author)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-body">{author?.display_name || query.data.author}</span>
|
||||
{/* README §4.3: border style, no fill (matches MessageBubble.tsx's MessageRoleTag) --
|
||||
this used to hardcode warn/orange for BOTH owner and admin authors, so an admin's
|
||||
own announcement showed the wrong (owner) color. */}
|
||||
{author?.role === 'owner' ? (
|
||||
<span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">{ROLE_LABEL.owner}</span>
|
||||
) : author?.role === 'admin' ? (
|
||||
<span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">{ROLE_LABEL.admin}</span>
|
||||
) : null}
|
||||
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
|
||||
</div>
|
||||
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
|
||||
{/* README §4.8. Both the flat fields and `read_summary` carry the same two numbers
|
||||
(confirmed reading the backend's own payload builder) -- prefer the flat ones,
|
||||
fall back to the nested object in case a future response only sends one shape. */}
|
||||
{(() => {
|
||||
const readCount = query.data.read_count ?? query.data.read_summary?.read_count;
|
||||
const memberCount = query.data.member_count ?? query.data.read_summary?.member_count;
|
||||
if (readCount == null || memberCount == null) return null;
|
||||
return (
|
||||
<p className="m-0 mt-2 text-xs text-text-tertiary-3">
|
||||
已读 {readCount}/{memberCount}
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { GroupAdminModal } from '@/features/groups/GroupAdminModal';
|
||||
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
|
||||
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
|
||||
import { canEditGroupAvatar, groupAvatarError } from '@/features/groups/group-settings';
|
||||
import {
|
||||
canRemoveMember,
|
||||
disbandGroup,
|
||||
getGroup,
|
||||
getMyGroupProfile,
|
||||
getGroupMembers,
|
||||
getGroupSettings,
|
||||
getGroupSummary,
|
||||
leaveGroup,
|
||||
updateGroup,
|
||||
updateGroupMemberSettings,
|
||||
updateMyGroupNickname,
|
||||
type GroupMember,
|
||||
type GroupProfile,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/groups';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||||
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { forgetTopic } from '@/lib/tinode/topics';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { requestActionToken } from '@/stores/securityChallenge';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
interface GroupDetailColumnProps {
|
||||
topicId: string;
|
||||
peerName: string;
|
||||
onClose?: () => void;
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
// README §4.10/#1e: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
|
||||
// (18×18 knob) -- this used to be the desktop size unconditionally. The travel distance
|
||||
// happens to stay 18px at both sizes (both track and knob grow by the same 4px, so the gap
|
||||
// between them is unchanged), so only the track/knob dimensions need a `md:` fork, not the
|
||||
// translate value.
|
||||
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** README §4.1's owner/admin corner tags on the 5-col member grid. */
|
||||
function RoleCorner({ role }: { role: GroupMember['role'] }) {
|
||||
if (role === 'owner') return <span className="absolute -left-0.5 -top-0.5 rounded bg-warn px-1 text-[9px] text-white">主</span>;
|
||||
if (role === 'admin') return <span className="absolute -left-0.5 -top-0.5 rounded bg-accent px-1 text-[9px] text-white">管</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
|
||||
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
|
||||
* almost entirely REST per Ch.6 of the protocol doc, not native Tinode `{set}` like a P2P topic's
|
||||
* desc/private would use -- confirmed against the old app's actually-shipped group-management-view.jsx).
|
||||
*/
|
||||
export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: GroupDetailColumnProps) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const tinode = useTinode();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [nicknameEditing, setNicknameEditing] = useState(false);
|
||||
const [nicknameDraft, setNicknameDraft] = useState('');
|
||||
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
|
||||
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
|
||||
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
|
||||
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
|
||||
// level (as opposed to per-member) actions.
|
||||
const [nameEditing, setNameEditing] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
|
||||
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
|
||||
// GroupDetailColumn is reused across every group when docked, no `key={topicId}`.
|
||||
useLayoutEffect(() => {
|
||||
setNicknameEditing(false);
|
||||
setNicknameDraft('');
|
||||
setNameEditing(false);
|
||||
setNameDraft('');
|
||||
setErrorToast('');
|
||||
setOpenModal(null);
|
||||
}, [topicId]);
|
||||
|
||||
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
|
||||
const settingsQuery = useQuery({ queryKey: ['group-settings', topicId], queryFn: () => getGroupSettings(topicId) });
|
||||
// `GET .../settings`'s real response has no `announcement` field at all (confirmed live,
|
||||
// 2026-09-08) despite this file originally typing one on GroupSettings -- the summary endpoint
|
||||
// is the one that actually carries it (also used by ConversationView's notification bar and
|
||||
// GroupAdminModal's join-request badge, so this is a cache hit more often than not).
|
||||
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
|
||||
const membersQuery = useQuery({ queryKey: ['group-members-preview', topicId], queryFn: () => getGroupMembers(topicId, {}) });
|
||||
// The nickname row needs the per-user profile endpoint. Uses the
|
||||
// dedicated `members/me/profile` route, not getGroupMemberProfile(topic, myUserId) -- see that
|
||||
// function's own warning for why the generic by-id path 400s on self-lookups.
|
||||
const myProfileQuery = useQuery({
|
||||
queryKey: ['group-my-profile', topicId, myUserId],
|
||||
queryFn: () => getMyGroupProfile(topicId),
|
||||
enabled: !!myUserId,
|
||||
});
|
||||
const myRole = settingsQuery.data?.my_role;
|
||||
const isOwner = myRole === 'owner';
|
||||
const isAdmin = myRole === 'admin' || isOwner;
|
||||
const canEditAvatar = canEditGroupAvatar(
|
||||
settingsQuery.data?.permissions?.can_edit_profile ?? groupQuery.data?.permissions?.can_edit_profile,
|
||||
myRole,
|
||||
);
|
||||
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
|
||||
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
|
||||
// owner/admin-only, so flipping that toggle to "所有成员" had zero effect on what a plain member
|
||||
// could actually see. `member_invite` lives on this same `settingsQuery` response already in scope
|
||||
// (no extra request needed) -- live-verified this reflects the real value (flipped it via
|
||||
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
|
||||
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
|
||||
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
|
||||
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
|
||||
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
|
||||
// this setting.
|
||||
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === true;
|
||||
|
||||
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
|
||||
// one 400s every single call (this was the actual root cause of "member-settings always 400",
|
||||
// not an optimistic-lock issue -- confirmed live 2026-09-09 by reading the backend's request
|
||||
// struct, which doesn't declare a version field at all).
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) => updateGroupMemberSettings(topicId, patch),
|
||||
// The response is the small {pinned, mute_notifications, version, updated_at} shape, not a full
|
||||
// GroupSettings -- merge instead of replacing, or every other cached field (name/owner/governance/
|
||||
// member_count/...) would vanish from the settings panel after the first successful toggle.
|
||||
onSuccess: (data) =>
|
||||
queryClient.setQueryData(['group-settings', topicId], (prev: GroupSettings | undefined) =>
|
||||
prev ? { ...prev, pinned: data.pinned, mute_notifications: data.mute_notifications } : prev,
|
||||
),
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const nicknameMutation = useMutation({
|
||||
mutationFn: (nickname: string) => updateMyGroupNickname(topicId, nickname, myProfileQuery.data?.version ?? 1),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicId, myUserId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
setNicknameEditing(false);
|
||||
},
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
const nameMutation = useMutation({
|
||||
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
|
||||
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
|
||||
// ever turns out to return a partial object like member-settings did.
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
|
||||
setNameEditing(false);
|
||||
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
|
||||
// this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
|
||||
// which this account won't receive for its own action. `data` here is already the fresh REST
|
||||
// response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
|
||||
// see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
|
||||
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
|
||||
// return stale data against this backend, so it's not used anywhere in this fix anymore.
|
||||
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
|
||||
},
|
||||
onError: (err) => showError(err, '群名保存失败'),
|
||||
});
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: async ({ file, topic, version }: { file: File; topic: string; version: number }) => {
|
||||
const uploaded = await uploadFile(file, undefined, 'group_avatar');
|
||||
return { ...(await updateGroup(topic, { avatar_file_id: uploaded.file_id, version })), avatar_file_id: uploaded.file_id };
|
||||
},
|
||||
onSuccess: (data, { topic }) => {
|
||||
queryClient.setQueryData(['group', topic], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topic] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-settings', topic] });
|
||||
queryClient.invalidateQueries({ queryKey: ['groups', 'all'] });
|
||||
},
|
||||
onError: (err) => showError(err, '群头像修改失败'),
|
||||
});
|
||||
const avatarUploading = avatarMutation.isPending && avatarMutation.variables?.topic === topicId;
|
||||
|
||||
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
const error = groupAvatarError(file);
|
||||
if (error) setErrorToast(error);
|
||||
else if (groupQuery.data) avatarMutation.mutate({ file, topic: topicId, version: groupQuery.data.version });
|
||||
};
|
||||
|
||||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||||
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
|
||||
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
|
||||
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
|
||||
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
|
||||
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
|
||||
// late signal entirely once `forgetTopic` has already marked this name gone here.
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: () => leaveGroup(topicId),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '退出群聊失败'),
|
||||
});
|
||||
|
||||
const disbandMutation = useMutation({
|
||||
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
|
||||
onSuccess: () => {
|
||||
forgetTopic(topicId);
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '解散群聊失败'),
|
||||
});
|
||||
|
||||
const clearHistory = async () => {
|
||||
// See DetailColumn.tsx's identical clearHistory for why this copy changed: a soft delete
|
||||
// (`delMessagesAll(false)`) is a real, per-account server watermark (im_be's Topic.replyDelMsg),
|
||||
// not a local-only cache wipe a refresh or another device would undo.
|
||||
if (
|
||||
!(await requestConfirm({
|
||||
message: '确定清空本群的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响其他成员。',
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await tinode.getTopic(topicId).delMessagesAll(false);
|
||||
} catch (err) {
|
||||
showError(err, '清空聊天记录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
const startLeaveOrDisband = async () => {
|
||||
if (isOwner) {
|
||||
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
|
||||
const actionToken = await requestActionToken({
|
||||
scene: 'group_dissolve',
|
||||
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
|
||||
});
|
||||
if (!actionToken) return;
|
||||
disbandMutation.mutate(actionToken);
|
||||
} else {
|
||||
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
|
||||
leaveMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const members = membersQuery.data?.items || [];
|
||||
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
|
||||
// Real gap this fixes: `isAdmin` alone gates the "-移出" tile below, but canRemoveMember (the
|
||||
// per-row check the tile's own destination list uses) is strictly finer -- an admin can't remove
|
||||
// the owner or another admin, only the owner can remove an admin. A group with no plain members
|
||||
// (e.g. [owner, admin(me), admin(other)]) would show the tile yet land on a list with zero "移出"
|
||||
// buttons anywhere, a dead end. Checked against the same preview page the compact grid above
|
||||
// already renders from (not exhaustive for a group with more members than that page holds, but
|
||||
// that's the same tradeoff this component's own member grid already makes, not a new one).
|
||||
const canRemoveAnyone = isAdmin && members.some((m) => canRemoveMember(myUserId, myRole || 'member', m));
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
|
||||
{/* Desktop header (03-group.dc.html #1a): title left, close-X right -- unchanged. */}
|
||||
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||||
<strong className="text-[15px] font-semibold">群聊设置</strong>
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
{/* Mobile header (#1e, "详情栏内容平铺为整页"): back arrow / centered title / empty. This panel
|
||||
already renders full-screen below 960px (MessagesLayout's own `fixed inset-0 md:hidden`
|
||||
wrapper around ConversationDetailPanel) -- only its OWN header/content never got restyled
|
||||
to match until now. `onClose` doubles as "back": same prop the desktop close-X already
|
||||
calls, which just closes this full-screen overlay -- no separate navigation needed. */}
|
||||
<header className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-list-item bg-white px-2 md:hidden">
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[22px]" />
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<strong className="text-center text-[17px] font-semibold">群聊设置</strong>
|
||||
<span />
|
||||
</header>
|
||||
|
||||
{/* Content. Desktop: `md:contents` makes this wrapper (and the "card" wrapper just below it)
|
||||
disappear as boxes -- their children fall through to be direct flex children of `aside`
|
||||
exactly as before this change, so desktop stays pixel-identical. Mobile: a real scroll
|
||||
container (the `aside` above switches to `overflow-hidden`, handing scrolling to this
|
||||
instead) with the mock's `#f6f7f9` page background and 10px gaps between white "cards".
|
||||
The card GROUPING genuinely differs from desktop here, confirmed by reading both #1a and
|
||||
#1e side by side rather than assumed: mobile puts 群管理 with 群公告/群文件/查找聊天记录
|
||||
(not with 清空聊天记录/投诉举报 like desktop), and mobile's mock has no 我在本群的昵称 row
|
||||
at all -- kept anyway (see below) since it's a real, working desktop feature with no other
|
||||
entry point on mobile; silently dropping it would be a functional regression, not a
|
||||
faithful mobile port. */}
|
||||
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto bg-[#f6f7f9] py-2.5 md:contents">
|
||||
<div className="bg-white md:contents">
|
||||
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-none">
|
||||
<Avatar
|
||||
id={topicId}
|
||||
name={groupQuery.data?.name || peerName}
|
||||
avatarFileId={groupQuery.data?.avatar_file_id}
|
||||
className="h-13 w-13 rounded-xl text-xl"
|
||||
/>
|
||||
{canEditAvatar && groupQuery.data ? (
|
||||
<label className="absolute -bottom-1 -right-1 grid h-6 w-6 cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm" aria-label="修改群头像">
|
||||
{avatarUploading ? <span className="text-[9px]">上传中</span> : <Icon name="photo_camera" className="text-[14px]" />}
|
||||
<input type="file" accept="image/*" onChange={handlePickAvatar} disabled={avatarUploading || nameMutation.isPending} className="hidden" />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{nameEditing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={nameDraft}
|
||||
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||||
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && !avatarUploading && nameMutation.mutate(nameDraft.trim())}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||||
disabled={!nameDraft.trim() || nameMutation.isPending || avatarUploading}
|
||||
className="flex-none text-sm text-accent disabled:opacity-40"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
|
||||
like the 我在本群的昵称 row further down, so this follows this file's other
|
||||
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
|
||||
{isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="修改群名"
|
||||
onClick={() => {
|
||||
setNameDraft(groupQuery.data?.name || '');
|
||||
setNameEditing(true);
|
||||
}}
|
||||
className="flex-none text-text-tertiary-3"
|
||||
>
|
||||
<Icon name="edit" className="text-[16px]" />
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-tertiary-2">
|
||||
群号 {topicId} · {memberCount} 人
|
||||
</span>
|
||||
</div>
|
||||
{/* 03-group.dc.html #1a: `qr_code_2` icon, `margin-left:auto` -- a real, mock-confirmed
|
||||
entry point this row never had. #1e's mobile avatar row instead ends in a generic
|
||||
`chevron_right` (implying a deeper group-profile screen this app doesn't have), not
|
||||
this icon -- but shipping a REAL, working QR entry point on mobile too (even if its
|
||||
glyph doesn't literally match #1e's) beats a chevron that taps through to nowhere. */}
|
||||
<button type="button" onClick={() => setOpenModal('qrcode')} aria-label="群二维码" className="flex-none text-text-tertiary-3">
|
||||
<Icon name="qr_code_2" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{groupQuery.data?.description ? <p className="m-0 text-[13px] text-text-secondary">{groupQuery.data.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-4 py-3 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
{/* Desktop-only header row (#1a): "群成员 N · 查看全部" above the grid. Mobile (#1e) has
|
||||
no such row -- it puts the same "查看全部成员" affordance as plain centered text
|
||||
BELOW the grid instead (see the mobile-only button after the grid). */}
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="hidden w-full items-center justify-between py-1 md:flex">
|
||||
<span className="text-text-body">群成员 {memberCount}</span>
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-accent">
|
||||
查看全部
|
||||
<Icon name="chevron_right" className="text-[18px]" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-5 gap-2 md:mt-2">
|
||||
{members.slice(0, 9).map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => openUserCard(m.user_id)}
|
||||
className="relative flex flex-col items-center gap-1"
|
||||
>
|
||||
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
|
||||
desktop's 40px (unconditional here before this fix). */}
|
||||
<Avatar
|
||||
id={m.user_id}
|
||||
name={groupMemberDisplayName(m)}
|
||||
avatarFileId={m.avatar_file_id}
|
||||
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
||||
/>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{groupMemberDisplayName(m)}</span>
|
||||
</button>
|
||||
))}
|
||||
{canInvite ? (
|
||||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||||
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
|
||||
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
|
||||
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
|
||||
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
|
||||
// plain member list -- only THIS tile's whole purpose is inviting.
|
||||
<button type="button" onClick={() => setOpenModal('invite')} className="flex flex-col items-center gap-1">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
|
||||
<Icon name="add" className="text-[18px]" />
|
||||
</span>
|
||||
<span className="text-[11px] text-text-secondary">邀请</span>
|
||||
</button>
|
||||
) : null}
|
||||
{canRemoveAnyone ? (
|
||||
// Sibling tile to "+邀请" right above -- same dashed-square treatment, mirrored icon.
|
||||
// A single standalone entry point into the plain member list (not a per-avatar corner
|
||||
// badge, and not a direct remove action) -- the actual "移出" control already lives on
|
||||
// each removable row inside that list. Explicit user correction after a first attempt
|
||||
// put a "-" badge on every removable avatar's own corner: that read as "many separate
|
||||
// remove buttons" when the intent was one "+"-shaped entry point, mirroring "+邀请".
|
||||
// Gated on canRemoveAnyone (not bare isAdmin) so the tile doesn't show in a group made
|
||||
// entirely of owner+admins with nobody actually removable -- a dead end otherwise.
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
|
||||
<Icon name="remove" className="text-[18px]" />
|
||||
</span>
|
||||
<span className="text-[11px] text-text-secondary">移出</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
|
||||
查看全部成员 ({memberCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
<button type="button" onClick={() => setOpenModal('announcement')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">群公告</span>
|
||||
<span className="flex max-w-[65%] items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
<span className="truncate">{summaryQuery.data?.announcement?.content || '未设置'}</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpenModal('files')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">群文件</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenHistory('text')} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<button type="button" onClick={() => setOpenModal('admin')} className="flex items-center justify-between py-3 text-left md:hidden">
|
||||
<span className="flex items-center gap-2 text-text-body">
|
||||
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
|
||||
群管理
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Not in #1e's mock at all (see the block comment above) -- kept for parity with desktop,
|
||||
which does have this row (just grouped with 群管理/清空聊天记录 there instead). */}
|
||||
{nicknameEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">我在本群的昵称</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={nicknameDraft}
|
||||
onChange={(e) => setNicknameDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && nicknameMutation.mutate(nicknameDraft)}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => nicknameMutation.mutate(nicknameDraft)} className="flex-none text-accent">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNicknameDraft(myProfileQuery.data?.nickname || '');
|
||||
setNicknameEditing(true);
|
||||
}}
|
||||
className="flex items-center justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">我在本群的昵称</span>
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
{myProfileQuery.data?.nickname || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">置顶聊天</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.pinned}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">消息免打扰</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.mute_notifications}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col bg-white px-4 pb-2 text-sm md:px-5">
|
||||
{/* Desktop groups 群管理 here (#1a); mobile already rendered it in the card above (#1e),
|
||||
so this copy is md-only to avoid showing the row twice. */}
|
||||
{isAdmin ? (
|
||||
<button type="button" onClick={() => setOpenModal('admin')} className="hidden items-center justify-between py-3 text-left md:flex">
|
||||
<span className="flex items-center gap-2 text-text-body">
|
||||
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
|
||||
群管理
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">清空聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">投诉举报</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop (#1a): bordered pill button, centered with margin -- unchanged. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startLeaveOrDisband}
|
||||
disabled={leaveMutation.isPending || disbandMutation.isPending}
|
||||
className="mx-5 my-4 hidden h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text disabled:opacity-50 md:block"
|
||||
>
|
||||
{isOwner ? '解散群聊' : '退出群聊'}
|
||||
</button>
|
||||
{/* Mobile (#1e): its own plain full-width white card, no border/radius. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startLeaveOrDisband}
|
||||
disabled={leaveMutation.isPending || disbandMutation.isPending}
|
||||
className="flex-none bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50 md:hidden"
|
||||
>
|
||||
{isOwner ? '解散群聊' : '退出群聊'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
|
||||
{openModal === 'members' || openModal === 'invite' ? (
|
||||
<GroupMembersModal
|
||||
topicId={topicId}
|
||||
canInvite={canInvite}
|
||||
myUserId={myUserId}
|
||||
myRole={myRole || 'member'}
|
||||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
/>
|
||||
) : null}
|
||||
{openModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
|
||||
) : null}
|
||||
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { addGroupFile, deleteGroupFile, getAllGroupMembers, getGroupFiles, type GroupFile } from '@/lib/api/groups';
|
||||
import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
|
||||
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
function isPdf(f: GroupFile): boolean {
|
||||
return f.mime_type === 'application/pdf' || f.file_name.toLowerCase().endsWith('.pdf');
|
||||
}
|
||||
|
||||
/** Authenticated fetch-to-blob, shared by download and "open in new tab" -- both need the same
|
||||
* custom auth header a plain `<a href>`/`window.open` can't carry (see AttachmentPreview.tsx's own
|
||||
* comment on this constraint), they just do different things with the resulting blob URL. */
|
||||
async function fetchFileBlobUrl(fileId: string): Promise<string> {
|
||||
const res = await fetch(fileDownloadUrl(`/v0/file/s/${fileId}`), { headers: fileAuthHeaders() });
|
||||
// Group files are GC'd as "unreferenced" within hours (known backend issue) -- without this
|
||||
// check, a 404's JSON error body gets treated as if it were the real file.
|
||||
if (!res.ok) throw new Error();
|
||||
return URL.createObjectURL(await res.blob());
|
||||
}
|
||||
|
||||
interface GroupFilesModalProps {
|
||||
topicId: string;
|
||||
/** Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupFiles): delete is
|
||||
* allowed for the uploader OR a group admin/owner, not the uploader alone -- without this, an
|
||||
* owner/admin has no way to remove another member's upload from this UI even though the server
|
||||
* would accept the request. */
|
||||
isAdmin: boolean;
|
||||
/** `governance.member_upload_files` (所有成员/仅管理员), computed by the caller alongside
|
||||
* `isAdmin` the exact same way `GroupDetailColumn`'s own `canInvite` already is -- without this,
|
||||
* the toggle in GroupAdminModal had zero effect on this button, the same class of bug
|
||||
* `canInvite` was already fixed for. */
|
||||
canUpload: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
|
||||
|
||||
/** `POST .../files`'s `file_type` field has no documented enum anywhere in the protocol doc -- ported
|
||||
* from the old app's own real, working `group-management-view.jsx` (mime-prefix first, a document-
|
||||
* extension allowlist as fallback), which itself notes this field being omitted entirely used to
|
||||
* make every group file show a generic icon regardless of real type -- the read-side icon-branching
|
||||
* bug found in this rewrite's own review pass had the same root cause. */
|
||||
function fileTypeFromMime(mime: string, name: string): string {
|
||||
const prefix = mime.split('/')[0];
|
||||
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
|
||||
const ext = /\.([a-zA-Z0-9]+)$/.exec(name)?.[1]?.toLowerCase();
|
||||
if (mime === 'application/pdf' || (ext && DOCUMENT_EXT.includes(ext))) return 'document';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.9 (480px). Files are registered via the message-send path (attachments.ts) or a direct
|
||||
* upload here -- this view lists/uploads/downloads/deletes the group's file INDEX (lib/api/groups.ts's
|
||||
* getGroupFiles/addGroupFile/deleteGroupFile). Upload reuses the same native `/v0/file/u/` channel as
|
||||
* chat attachments/avatars (uploadFile), then registers the result via addGroupFile -- which needs
|
||||
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
|
||||
* from the old app's real, working upload flow rather than guessed from the doc.
|
||||
*/
|
||||
export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupFilesModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
const [downloadError, setDownloadError] = useState('');
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const filesQuery = useInfiniteQuery({
|
||||
queryKey: ['group-files', topicId, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => getGroupFiles(topicId, debouncedQuery, '', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const files = filesQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
// `GroupFile.uploader_id` is a bare user id with no display name attached (confirmed live) --
|
||||
// mock `03-group.dc.html #1h`'s rows all show "size · uploader · time", not just "size · time".
|
||||
// Same lookup-by-id-against-the-member-list pattern GroupAnnouncementModal already uses for its
|
||||
// own author row.
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const uploaderName = (uploaderId: string) => membersQuery.data?.find((m) => m.user_id === uploaderId)?.display_name || uploaderId;
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
// Confirmed live: the upload response never echoes back name/size/mime (see upload.ts's own
|
||||
// UploadedFile comment) -- read them off the original File object instead, exactly like the
|
||||
// chat-attachment upload path (lib/tinode/attachments.ts) already correctly does.
|
||||
const uploaded = await uploadFile(file, topicId);
|
||||
await addGroupFile(topicId, {
|
||||
file_id: uploaded.file_id,
|
||||
file_name: file.name,
|
||||
mime_type: file.type,
|
||||
file_type: fileTypeFromMime(file.type, file.name),
|
||||
file_size: file.size,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['group-files', topicId] });
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (groupFileId: string) => deleteGroupFile(topicId, groupFileId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['group-files', topicId] }),
|
||||
onError: (err) => setActionError(err instanceof Error ? err.message : '删除失败'),
|
||||
});
|
||||
|
||||
const downloadFile = (f: GroupFile) =>
|
||||
fetchFileBlobUrl(f.file_id)
|
||||
.then((url) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = f.file_name;
|
||||
link.click();
|
||||
// Keep the object URL alive long enough for the browser to start the download.
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
})
|
||||
.catch(() => setDownloadError('文件下载失败,可能已过期'));
|
||||
|
||||
const openFileInNewTab = (f: GroupFile) =>
|
||||
fetchFileBlobUrl(f.file_id)
|
||||
.then((url) => window.open(url, '_blank', 'noopener,noreferrer'))
|
||||
.catch(() => setDownloadError('文件打开失败,可能已过期'));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[520px] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
|
||||
the neutral gray this used to be. Hidden (not just disabled) when governance says
|
||||
only admins may upload and this viewer isn't one -- there's no "ask an admin" action
|
||||
a plain member could take from here, so a visible-but-blocked button would only
|
||||
invite a confusing click. */}
|
||||
{canUpload ? (
|
||||
<label
|
||||
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
|
||||
>
|
||||
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
|
||||
上传
|
||||
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
|
||||
</label>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-none px-4 pt-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索群文件"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{filesQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : filesQuery.isError ? (
|
||||
<div className="py-10 text-center text-sm text-danger-text">加载失败,请重试</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群文件</div>
|
||||
) : (
|
||||
<>
|
||||
{files.map((f) => {
|
||||
const style = fileTypeStyle(f.file_name, f.mime_type);
|
||||
// No `permissions` field exists on the real response (see groups.ts's own comment),
|
||||
// but the real delete authorization (confirmed against im_be/server/im_http.go's
|
||||
// serveIMGroupFiles) is uploader OR group admin/owner -- `isAdmin` is passed down from
|
||||
// GroupDetailColumn, which already resolves the caller's real role for this same group.
|
||||
const canDelete = isAdmin || (!!myUserId && f.uploader_id === myUserId);
|
||||
const pdf = isPdf(f);
|
||||
// 03-group.dc.html #1h draws three DIFFERENT trailing icons across its 3 example
|
||||
// rows: a plain `download` for a generic .docx, `open_in_new` for a .pdf (browsers
|
||||
// can render PDFs inline, so "open" is more useful than "save"), and `more_horiz` for
|
||||
// the row whose uploader is "我" (me) -- i.e. the icon is a function of BOTH file type
|
||||
// AND whether this is my own upload, not either one alone.
|
||||
return (
|
||||
<div key={f.group_file_id} className="relative flex items-center gap-1 rounded-lg hover:bg-surface-rail">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (pdf ? openFileInNewTab(f) : downloadFile(f))}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-3 py-2 text-left"
|
||||
>
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{f.file_name}</span>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{formatFileSize(f.file_size)} · {uploaderName(f.uploader_id)} · {formatEventTimestamp(new Date(f.created_at))}
|
||||
</span>
|
||||
</span>
|
||||
{canDelete ? null : <Icon name={pdf ? 'open_in_new' : 'download'} className="flex-none text-[20px] text-text-placeholder-2" />}
|
||||
</button>
|
||||
{canDelete ? (
|
||||
<FileMenu
|
||||
isPdf={pdf}
|
||||
open={openMenuId === f.group_file_id}
|
||||
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
|
||||
// Functional update, not a plain `setOpenMenuId(null)` -- this fires from
|
||||
// useClickOutside's window-level listener, which runs AFTER another row's own
|
||||
// onToggle already ran in the same click (React's synthetic onToggle always
|
||||
// precedes a window listener in the bubble order). An unconditional null would
|
||||
// clobber that other row's freshly-opened state; only clear if this row is
|
||||
// still the one actually open.
|
||||
onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={async () => {
|
||||
setOpenMenuId(null);
|
||||
if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) {
|
||||
deleteMutation.mutate(f.group_file_id);
|
||||
}
|
||||
}}
|
||||
deleting={deleteMutation.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filesQuery.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => filesQuery.fetchNextPage()}
|
||||
disabled={filesQuery.isFetchingNextPage}
|
||||
className="w-full py-3 text-center text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{filesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={downloadError} variant="error" onDone={() => setDownloadError('')} />
|
||||
<Toast message={actionError} variant="error" onDone={() => setActionError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The mock's `more_horiz` trailing icon for a file I uploaded, opening a small anchored menu with
|
||||
* the actions the plain trailing icon otherwise carries alone (预览/下载) plus 删除 -- no mock draws
|
||||
* the open menu itself (only the closed `more_horiz` icon), so the menu's own layout borrows the
|
||||
* small-popover convention already used for MessageContextMenu.tsx (white card, shadow, icon+label
|
||||
* rows, a divider before the destructive action). */
|
||||
function FileMenu({
|
||||
isPdf,
|
||||
open,
|
||||
onToggle,
|
||||
onClose,
|
||||
onDownload,
|
||||
onOpen,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
isPdf: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
onDownload: () => void;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
deleting: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useClickOutside(ref, onClose, open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative flex-none">
|
||||
<button type="button" aria-label="更多操作" onClick={onToggle} className="grid h-8 w-8 place-items-center rounded-md text-text-tertiary-3 hover:bg-white">
|
||||
<Icon name="more_horiz" className="text-[20px]" />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg bg-white py-1 shadow-modal">
|
||||
{isPdf ? (
|
||||
<button type="button" onClick={onOpen} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
|
||||
<Icon name="open_in_new" className="text-[18px] text-text-tertiary-2" />
|
||||
预览
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onDownload} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
|
||||
<Icon name="download" className="text-[18px] text-text-tertiary-2" />
|
||||
下载
|
||||
</button>
|
||||
<div className="my-1 border-t border-border-list-item" />
|
||||
<button type="button" onClick={onDelete} disabled={deleting} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger-text disabled:opacity-50">
|
||||
<Icon name="delete_outline" className="text-[18px]" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
|
||||
// MessageRoleTag exactly (this file previously used filled `bg-*-bg` pills instead).
|
||||
const ROLE_CLASS: Record<GroupRole, string> = { owner: 'border border-warn text-warn', admin: 'border border-accent text-accent', member: '' };
|
||||
|
||||
function RoleTag({ role }: { role: GroupRole }) {
|
||||
if (role === 'member') return null;
|
||||
return <span className={`flex-none rounded-[3px] px-1 text-[10px] leading-[14px] ${ROLE_CLASS[role]}`}>{ROLE_LABEL[role]}</span>;
|
||||
}
|
||||
|
||||
interface GroupMembersModalProps {
|
||||
topicId: string;
|
||||
/** Whether the current viewer can invite -- owner/admin, OR any member when the group's
|
||||
* `governance.member_invite` toggle (GroupAdminModal's "邀请入群权限") is set to "所有成员".
|
||||
* Computed by the caller (GroupDetailColumn already has both `my_role` and `governance` in scope
|
||||
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
|
||||
* what silently made that governance toggle a no-op before this was wired through. */
|
||||
canInvite: boolean;
|
||||
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember, lib/api/groups.ts),
|
||||
* mirroring the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
|
||||
myUserId: string | null;
|
||||
myRole: GroupRole;
|
||||
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
|
||||
* the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through
|
||||
* here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请"
|
||||
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
|
||||
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
|
||||
* (查看全部成员 etc.), which should still land on the member list as before. */
|
||||
initialView?: 'members' | 'invite';
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
|
||||
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
|
||||
* an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
|
||||
* documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
|
||||
* `DELETE /groups/{topic}/members/{user_id}` (see removeGroupMember's own comment), so this is no
|
||||
* longer a backend gap. */
|
||||
/** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember`
|
||||
* (a person can be successfully invited without becoming a member yet -- see the "pending"/
|
||||
* "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
|
||||
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
|
||||
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
|
||||
type InviteOutcome = GroupInvitationResult['status'];
|
||||
|
||||
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
|
||||
joined: '已加入',
|
||||
already_member: '已在群内',
|
||||
pending: '邀请已发送',
|
||||
pending_approval: '待对方确认',
|
||||
failed: '邀请失败',
|
||||
};
|
||||
|
||||
|
||||
export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(initialView === 'invite');
|
||||
const [error, setError] = useState('');
|
||||
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
|
||||
// Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own
|
||||
// `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly
|
||||
// also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the
|
||||
// moment B's click fires, even though A's own request may still be in flight.
|
||||
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const members = membersQuery.data || [];
|
||||
const filtered = members.filter((m) => !query.trim() || groupMemberDisplayName(m).toLowerCase().includes(query.trim().toLowerCase()));
|
||||
|
||||
const friendsQuery = useQuery({
|
||||
queryKey: ['friends'],
|
||||
queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)),
|
||||
enabled: inviting,
|
||||
});
|
||||
// README §4.7: friends already in the group stay VISIBLE, dimmed + labeled + unselectable -- not
|
||||
// silently filtered out (which used to make the list look shorter than the user's real friend
|
||||
// list with no explanation why some names were missing).
|
||||
const inviteCandidates = (friendsQuery.data || []).map((f) => ({ ...f, alreadyMember: members.some((m) => m.user_id === f.user_id) }));
|
||||
|
||||
const inviteMutation = useMutation({
|
||||
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
|
||||
onSuccess: (data, userId) => {
|
||||
// A real, previously-missing gap this fixes: the old version only ever refetched the member
|
||||
// list and left it at that -- for the common "joined immediately" case that's a silent success
|
||||
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
|
||||
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
|
||||
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
|
||||
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
|
||||
// user_id at a time.
|
||||
const outcome = data.items[0]?.status;
|
||||
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
|
||||
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (userId: string) => removeGroupMember(topicId, userId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '移出失败'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">{inviting ? '邀请成员' : `群成员 ${members.length}`}</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
{!inviting && canInvite ? (
|
||||
<button type="button" onClick={() => setInviting(true)} className="text-sm text-accent">
|
||||
邀请
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={inviting ? () => setInviting(false) : onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name={inviting ? 'arrow_back' : 'close'} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-none px-4 pt-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={inviting ? '搜索联系人' : '搜索群成员'}
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{inviting ? (
|
||||
inviteCandidates
|
||||
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.map((f) => {
|
||||
const label = f.remark || f.display_name || f.user_id;
|
||||
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
|
||||
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
|
||||
// both are somehow true (the member-list refetch above should already have cleared the
|
||||
// "joined" outcome to alreadyMember=true by then anyway).
|
||||
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
|
||||
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
|
||||
// and there's no harm in letting the same click retry (a genuinely permanent failure,
|
||||
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
|
||||
// a real, done state with nothing left to retry.
|
||||
const settled = !!outcome && outcome !== 'failed';
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
disabled={inviteMutation.isPending || settled}
|
||||
onClick={() => inviteMutation.mutate(f.user_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
|
||||
settled ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
|
||||
{outcome ? (
|
||||
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="person_add" className="text-[18px] text-accent" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
) : membersQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : (
|
||||
filtered.map((m) => (
|
||||
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
|
||||
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(groupMemberDisplayName(m))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{groupMemberDisplayName(m)}</span>
|
||||
</button>
|
||||
<RoleTag role={m.role} />
|
||||
{canRemoveMember(myUserId, myRole, m) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={removingIds.has(m.user_id)}
|
||||
onClick={async () => {
|
||||
const label = groupMemberDisplayName(m);
|
||||
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
|
||||
setRemovingIds((prev) => new Set(prev).add(m.user_id));
|
||||
removeMutation.mutate(m.user_id, {
|
||||
onSettled: () =>
|
||||
setRemovingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(m.user_id);
|
||||
return next;
|
||||
}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline"
|
||||
>
|
||||
{removingIds.has(m.user_id) ? (
|
||||
'移出中…'
|
||||
) : (
|
||||
<>
|
||||
<Icon name="remove_circle" className="text-sm" />
|
||||
移出
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { acceptGroupJoinRequest, getAllGroupMembers, getGroupJoinRequests, rejectGroupJoinRequest, type GroupJoinRequest } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
interface JoinRequestsModalProps {
|
||||
topicId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §4.6. Reject sends `{version}` ONLY -- no `reason` field, even though the protocol doc's
|
||||
* own example shows one (see lib/api/groups.ts's rejectGroupJoinRequest comment: the old app
|
||||
* confirmed live that the real server 400s on an unrecognized `reason` key).
|
||||
*/
|
||||
export function JoinRequestsModal({ topicId, onClose }: JoinRequestsModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['group-join-requests', topicId],
|
||||
// `'pending'`-only used to mean a request resolved before this session (or by another admin)
|
||||
// just never appeared here at all -- the render below already has a real "已同意/已拒绝" branch
|
||||
// keyed off the server's own `req.status`, it just never got fed anything but pending requests to
|
||||
// apply that branch to. README §4.6's own mock shows an already-resolved row persisting in the
|
||||
// list, matching `'all'`, not `'pending'`.
|
||||
queryFn: () => fetchAllPages<GroupJoinRequest>((cursor) => getGroupJoinRequests(topicId, 'all', cursor)),
|
||||
});
|
||||
// The applicant themself has no display name anywhere on the real response (see groups.ts's own
|
||||
// GroupJoinRequest comment) and isn't a member yet, so there's no list to resolve THEIR name from
|
||||
// either -- shown as a bare id, honestly, rather than guessed. The INVITER (mock: "王工 邀请") IS
|
||||
// already a member by definition, so that name can be resolved the same way GroupFilesModal's
|
||||
// uploader name and GroupAnnouncementModal's author name already do.
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const memberName = (userId: string) => membersQuery.data?.find((m) => m.user_id === userId)?.display_name || userId;
|
||||
|
||||
const invalidate = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
|
||||
};
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: (req: GroupJoinRequest) => acceptGroupJoinRequest(topicId, req.request_id, req.version),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (req: GroupJoinRequest) => rejectGroupJoinRequest(topicId, req.request_id, req.version),
|
||||
onSuccess: (_data, req) => {
|
||||
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
|
||||
// Unlike accept, nothing here changes group membership -- but `group-summary`'s
|
||||
// `pending_join_request_count` badge (read by GroupAdminModal's "进群申请" row) does need to
|
||||
// drop by one, and this previously invalidated nothing at all, leaving that badge stuck high.
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[480px] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">进群申请</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : !query.data?.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无进群申请</div>
|
||||
) : (
|
||||
query.data.map((req) => {
|
||||
const status = handled[req.request_id];
|
||||
return (
|
||||
<div key={req.request_id} className="flex items-center gap-3 px-3 py-2.5">
|
||||
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.user_id)}`}>
|
||||
{avatarLetter(req.user_id)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<strong className="truncate text-sm font-medium text-text-body">{req.user_id}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">
|
||||
{/* mock (#1c): "{邀请人} 邀请" when invited, else a source label -- NOT
|
||||
`source==='qr' ? ... : 邀请`, which used to show "邀请" for every
|
||||
non-qr source including a plain self-initiated search request that
|
||||
nobody actually invited (confirmed live: `source` really does take a
|
||||
`"search"` value distinct from an invite). `inviter_id` is the real
|
||||
signal for "was this an invite", not `source`. */}
|
||||
{req.inviter_id ? `${memberName(req.inviter_id)} 邀请` : req.source === 'qr' ? '扫码申请' : '申请加入'}
|
||||
{req.message ? ` · "${req.message}"` : ''} · {formatEventTimestamp(new Date(req.created_at))}
|
||||
</span>
|
||||
</span>
|
||||
{status || req.status !== 'pending' ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">
|
||||
{status === 'accepted' || req.status === 'accepted' ? '已同意' : '已拒绝'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex flex-none items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rejectMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => acceptMutation.mutate(req)}
|
||||
disabled={acceptMutation.isPending || rejectMutation.isPending}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
同意
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const privateChatOptions: { value: 'all' | 'friends'; label: string }[] = [
|
||||
{ value: 'all', label: '全员' },
|
||||
{ value: 'friends', label: '好友添加' },
|
||||
];
|
||||
|
||||
export function privateChatOption(enabled: boolean): 'all' | 'friends' {
|
||||
return enabled ? 'all' : 'friends';
|
||||
}
|
||||
|
||||
export function canEditGroupAvatar(permission: boolean | undefined, role: 'owner' | 'admin' | 'member' | undefined): boolean {
|
||||
return permission ?? (role === 'owner' || role === 'admin');
|
||||
}
|
||||
|
||||
export function groupAvatarError(file: Pick<File, 'type' | 'size'>): string | null {
|
||||
if (!file.type.startsWith('image/')) return '请选择图片文件';
|
||||
return file.size > 5 * 1024 * 1024 ? '图片大小不能超过 5MB' : null;
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
|
||||
interface ImageData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent
|
||||
* (see sendImage in lib/tinode/attachments.ts). */
|
||||
filename?: string;
|
||||
/** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android
|
||||
* app, which flows through a completely different (non-Drafty) flat shape than the web client's
|
||||
* own `IM` entity above; MessageBubble passes that content straight through as this same ImageData
|
||||
* shape, so both need a name field here even though the two clients don't call it the same thing. */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own
|
||||
* client attached (see ImageData's own field comments), falling back to a generic name derived from
|
||||
* the mime type for the inline-base64/mobile-relay cases where neither field is set. */
|
||||
function imageDownloadName(data: ImageData): string {
|
||||
if (data.filename) return data.filename;
|
||||
if (data.name) return data.name;
|
||||
const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
|
||||
return `image.${ext}`;
|
||||
}
|
||||
|
||||
const LIGHTBOX_ZOOM_MIN = 1;
|
||||
const LIGHTBOX_ZOOM_MAX = 4;
|
||||
const LIGHTBOX_ZOOM_STEP = 0.5;
|
||||
|
||||
/** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI
|
||||
* ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS
|
||||
* crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work.
|
||||
* Zoom is CSS transform scale/translate on the `<img>` itself, not a native browser zoom -- wheel
|
||||
* and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise
|
||||
* the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */
|
||||
function ImageLightbox({ src, filename, onClose }: { src: string; filename: string; onClose: () => void }) {
|
||||
const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
|
||||
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z));
|
||||
const applyZoom = (next: number) => {
|
||||
const clamped = clampZoom(next);
|
||||
setZoom(clamped);
|
||||
if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 });
|
||||
};
|
||||
const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP);
|
||||
const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP);
|
||||
const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX);
|
||||
|
||||
// Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't
|
||||
// guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is
|
||||
// silently ignored, which would leave the page scrolling underneath the lightbox while "zooming".
|
||||
useEffect(() => {
|
||||
const el = imgRef.current;
|
||||
if (!el) return undefined;
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP));
|
||||
};
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always
|
||||
// closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale
|
||||
// closure that would zoom from an outdated level.
|
||||
}, [zoom]);
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom <= LIGHTBOX_ZOOM_MIN) return;
|
||||
e.stopPropagation();
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) });
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80" onClick={onClose}>
|
||||
<div className="absolute right-6 top-6 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= LIGHTBOX_ZOOM_MIN}
|
||||
aria-label="缩小"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
|
||||
>
|
||||
<Icon name="zoom_out" className="text-[20px]" />
|
||||
</button>
|
||||
<span className="min-w-[3.5em] text-center text-sm text-white">{Math.round(zoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= LIGHTBOX_ZOOM_MAX}
|
||||
aria-label="放大"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
|
||||
>
|
||||
<Icon name="zoom_in" className="text-[20px]" />
|
||||
</button>
|
||||
{/* Real anchor + `download`, same technique FileAttachment already relies on below -- works
|
||||
for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches
|
||||
for larger ones, triggering a normal save-to-disk with no extra round trip. User report
|
||||
(老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on
|
||||
an `<img>`, but wasn't discoverable enough; this puts an explicit, visible affordance here,
|
||||
same place every mainstream IM's own "查看大图" view puts it. */}
|
||||
<a
|
||||
href={src}
|
||||
download={filename}
|
||||
aria-label="保存图片"
|
||||
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white"
|
||||
>
|
||||
<Icon name="download" className="text-[20px]" />
|
||||
</a>
|
||||
<button type="button" onClick={onClose} aria-label="关闭" className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white">
|
||||
<Icon name="close" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag
|
||||
target inside an already-dismissible dialog; alt="" is correct here, same reasoning as
|
||||
ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt=""
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleZoom();
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={endDrag}
|
||||
onMouseLeave={endDrag}
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
||||
className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same
|
||||
* reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same
|
||||
* thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so
|
||||
* entering select mode doesn't visually change every image bubble in the list. */
|
||||
export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [viewing, setViewing] = useState(false);
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
|
||||
const thumb = <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
||||
if (locked) return thumb;
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setViewing(true)} className="block">
|
||||
{thumb}
|
||||
</button>
|
||||
{viewing ? <ImageLightbox src={src} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row,
|
||||
* not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in
|
||||
* new tab" aren't specially guarded against, matching this codebase's existing level of rigor for
|
||||
* other locked-during-selectMode controls). */
|
||||
export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) {
|
||||
const style = fileTypeStyle(data.name, data.mime);
|
||||
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
|
||||
const href = inline || remote.url || undefined;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
download={data.name}
|
||||
onClick={(e) => {
|
||||
if (locked) {
|
||||
e.preventDefault();
|
||||
} else if (remote.error) {
|
||||
e.preventDefault();
|
||||
remote.retry();
|
||||
} else if (!href) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
|
||||
>
|
||||
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
|
||||
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
|
||||
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
|
||||
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
|
||||
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
|
||||
</span>
|
||||
</span>
|
||||
<Icon
|
||||
name={remote.error ? 'error_outline' : 'download'}
|
||||
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
interface VideoData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
preview?: string;
|
||||
previewRef?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
|
||||
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
|
||||
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
|
||||
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
|
||||
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
|
||||
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
|
||||
*
|
||||
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
|
||||
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
|
||||
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
|
||||
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
|
||||
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
|
||||
// Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
|
||||
// thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
|
||||
// video body itself), not the base64 `preview` field this component was built around for the web
|
||||
// client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
|
||||
// there's no inline poster, same short-circuit `remote` above already uses for the video body.
|
||||
const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
|
||||
const posterUrl = posterInline || posterRemote.url;
|
||||
const videoSrc = videoInline || remote.url;
|
||||
|
||||
if (playing && videoSrc) {
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
|
||||
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (locked) return;
|
||||
setPlaying(true);
|
||||
}}
|
||||
disabled={!videoSrc}
|
||||
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
|
||||
{typeof data.duration === 'number' ? (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
|
||||
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
|
||||
* Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
|
||||
* toggle action. */
|
||||
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
|
||||
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
|
||||
const src = inline || remote.url;
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [playError, setPlayError] = useState(false);
|
||||
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) return undefined;
|
||||
const onEnded = () => setPlaying(false);
|
||||
audioEl.addEventListener('ended', onEnded);
|
||||
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
|
||||
// playing leaves it playing in the background with no control left on screen to stop it.
|
||||
return () => {
|
||||
audioEl.removeEventListener('ended', onEnded);
|
||||
audioEl.pause();
|
||||
};
|
||||
}, [audioEl]);
|
||||
|
||||
const toggle = () => {
|
||||
if (locked) return;
|
||||
if (!audioEl || !src) return;
|
||||
if (playing) {
|
||||
audioEl.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
if (audioEl.src !== src) audioEl.src = src;
|
||||
setPlayError(false);
|
||||
audioEl
|
||||
.play()
|
||||
.then(() => setPlaying(true))
|
||||
.catch(() => setPlayError(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
|
||||
<Icon
|
||||
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
|
||||
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
|
||||
/>
|
||||
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
|
||||
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
|
||||
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
||||
import { friendAvatarLookup } from '@/features/messages/chat-list';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getFriends } from '@/lib/api/friends';
|
||||
import { getGroups } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { formatMessagePreview } from '@/lib/messages/preview';
|
||||
import { formatListTime } from '@/lib/messages/time';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* README §2.1's always-pinned utility row. Real, confirmed backend fact (docs/IM-前后端对接
|
||||
* 文档.md:909): topic name is fixed ("sys"), but 系统消息's unread comes from a separate
|
||||
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
|
||||
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
|
||||
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
|
||||
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
|
||||
* a route outside MessagesLayout entirely).
|
||||
*
|
||||
* 文件助手 (a pinned entry that redirected into a plain ConversationView on the `slf` topic --
|
||||
* README §9.2's own dedicated 3-column file browser has no backing REST API at all, confirmed via a
|
||||
* research pass) was deliberately removed from here: the user found it confusing -- it's really
|
||||
* just a chat thread with yourself wearing a folder icon, and DetailColumn's friend-only actions
|
||||
* (拉黑/举报/删除好友) made no sense pointed at yourself. Hidden across every entry point (NavRail,
|
||||
* here, ContactsListColumn, SettingsLayout's mobile shortcuts) rather than half-built into a real
|
||||
* file browser the backend can't support yet. `slf` itself is untouched -- if it ever has real
|
||||
* message history it still surfaces as an perfectly ordinary contact row, sorted like any other.
|
||||
*/
|
||||
const PINNED_ENTRIES = [
|
||||
{
|
||||
key: 'system_messages' as const,
|
||||
badgeKey: 'system_unread_count' as const,
|
||||
label: '系统消息',
|
||||
icon: 'notifications',
|
||||
route: '/messages/system',
|
||||
colorClass: 'bg-warn-bg text-warn',
|
||||
},
|
||||
];
|
||||
|
||||
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
|
||||
* descending within each group. The pinned flag is read straight off the SDK's own ambient
|
||||
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
|
||||
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
|
||||
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
|
||||
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
|
||||
* just to render the list. */
|
||||
export function ChatListColumn() {
|
||||
const { topicId } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
|
||||
// key so this is a cache read, not an extra request, in the common case that already mounted it.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
const tinode = useTinode();
|
||||
const contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
|
||||
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
|
||||
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
|
||||
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
|
||||
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
|
||||
const avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id]));
|
||||
// Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx):
|
||||
// a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught
|
||||
// to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's
|
||||
// exact `['friends']` query key, so this is a cache read whenever that page has already been visited.
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const avatarByPeer = friendAvatarLookup(friendsQuery.data);
|
||||
const [query, setQuery] = useState('');
|
||||
const [creatingGroup, setCreatingGroup] = useState(false);
|
||||
const sorted = [...contacts].sort((a, b) => {
|
||||
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
|
||||
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
|
||||
});
|
||||
const trimmedQuery = query.trim();
|
||||
const filtered = trimmedQuery
|
||||
? sorted.filter((c) =>
|
||||
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : c.public?.fn || c.topic || '')
|
||||
.toLowerCase()
|
||||
.includes(trimmedQuery.toLowerCase()),
|
||||
)
|
||||
: sorted;
|
||||
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
|
||||
<header className="flex h-15 flex-none items-center justify-between px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
|
||||
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
|
||||
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
|
||||
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
|
||||
<Icon name="add_comment" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索会话、联系人、消息"
|
||||
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{pinned.map((entry) => {
|
||||
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
|
||||
const active = location.pathname === entry.route;
|
||||
return (
|
||||
<Link
|
||||
key={entry.key}
|
||||
to={entry.route}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
|
||||
<Icon name={entry.icon} className="text-2xl" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
||||
{unread ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{unread}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
|
||||
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
|
||||
<span className="text-sm">{trimmedQuery ? '未找到相关会话' : '暂无会话'}</span>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((contact) => {
|
||||
const name = contact.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
|
||||
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
|
||||
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
|
||||
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
|
||||
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
|
||||
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
|
||||
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
||||
const muted = !!contact.private?.mute_notifications;
|
||||
const avatarFileId =
|
||||
contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByPeer.get(contact.topic);
|
||||
return (
|
||||
<Link
|
||||
key={contact.topic}
|
||||
to={`/messages/${contact.topic}`}
|
||||
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
|
||||
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<span className="relative block h-11 w-11 flex-none">
|
||||
<Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
|
||||
{contact.online ? (
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
|
||||
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
|
||||
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
|
||||
{typeof memberCount === 'number' && memberCount > 0 ? (
|
||||
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
|
||||
) : null}
|
||||
</span>
|
||||
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
|
||||
</span>
|
||||
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
|
||||
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
|
||||
{/* Same reference mock: a muted conversation's unread count is replaced by a small
|
||||
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
|
||||
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
|
||||
is to stop this row from demanding attention the way an unmuted one does. */}
|
||||
{muted ? (
|
||||
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
|
||||
) : contact.unread ? (
|
||||
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
|
||||
{contact.unread}
|
||||
</b>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creatingGroup ? (
|
||||
<CreateGroupModal
|
||||
onClose={() => setCreatingGroup(false)}
|
||||
onCreated={(topic) => {
|
||||
setCreatingGroup(false);
|
||||
navigate(`/messages/${topic}`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,347 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import {
|
||||
blockUser,
|
||||
deleteFriend,
|
||||
getBlocks,
|
||||
getConversationSettings,
|
||||
getFriends,
|
||||
getUserProfile,
|
||||
unblockUser,
|
||||
updateConversationSettings,
|
||||
updateFriendRemark,
|
||||
} from '@/lib/api/friends';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { getP2PPeerUserId } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface DetailColumnProps {
|
||||
topicId: string;
|
||||
peerName: string;
|
||||
onClose?: () => void;
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
|
||||
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
|
||||
// distance stays 18px at both sizes since track and knob grow by the same 4px.
|
||||
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: DetailColumnProps) {
|
||||
const navigate = useNavigate();
|
||||
const tinode = useTinode();
|
||||
const queryClient = useQueryClient();
|
||||
const [remarkEditing, setRemarkEditing] = useState(false);
|
||||
const [remarkDraft, setRemarkDraft] = useState('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
|
||||
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
|
||||
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
|
||||
// saved against the NEW one, and a leftover error toast would keep showing over the new contact
|
||||
// misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
|
||||
// this component never remounts on topicId change, so a post-paint effect can let one frame
|
||||
// render with the previous contact's stale edit box still open.
|
||||
useLayoutEffect(() => {
|
||||
setRemarkEditing(false);
|
||||
setRemarkDraft('');
|
||||
setErrorToast('');
|
||||
}, [topicId]);
|
||||
|
||||
// `slf` (self-chat, e.g. opened via a chat-list row once it has real history -- there is no
|
||||
// "文件助手" entry point pointing at it anymore, see ChatListColumn.tsx's PINNED_ENTRIES doc
|
||||
// comment) isn't a friend and isn't really "someone else" at all -- getUserProfile('slf') has no
|
||||
// real user to look up, and every friend-only action below (remark/block/report/delete friend)
|
||||
// is nonsensical pointed at yourself. Gated out entirely rather than left to silently no-op or
|
||||
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
|
||||
// to yourself.
|
||||
const isSelf = topicId === 'slf';
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
// NOT topicId -- see getP2PPeerUserId's own doc comment. Every lookup below that means "the other
|
||||
// user" (profile, friends-list match, block, remark) has to go through this, not the raw topic id,
|
||||
// because the two stop being interchangeable the instant this conversation has any real history.
|
||||
const peerUserId = isSelf ? topicId : getP2PPeerUserId(tinode.getTopic(topicId), myUserId);
|
||||
|
||||
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId);
|
||||
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile', peerUserId],
|
||||
queryFn: () => getUserProfile(peerUserId || ''),
|
||||
// `!isSelf` alone isn't enough now that peerUserId can genuinely be undefined for a beat (the
|
||||
// subscriber list not loaded yet) -- without also gating on it, this fires with `undefined` the
|
||||
// instant isSelf is false, same class of guaranteed-404 bug this whole fix targets, just with
|
||||
// the literal string "undefined" as the id instead of the p2p topic name.
|
||||
enabled: !isSelf && !!peerUserId,
|
||||
});
|
||||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
// Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever
|
||||
// drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a
|
||||
// real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend`
|
||||
// (the `/friends` list, same source the contacts pages already trust) takes priority since it's the
|
||||
// one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend.
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
|
||||
const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id);
|
||||
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ['conversation-settings', topicId],
|
||||
queryFn: () => getConversationSettings(topicId),
|
||||
});
|
||||
|
||||
const updateSettings = useMutation({
|
||||
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
|
||||
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
|
||||
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const remarkMutation = useMutation({
|
||||
mutationFn: (remark: string) => updateFriendRemark(peerUserId, remark),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
setRemarkEditing(false);
|
||||
},
|
||||
onError: (err) => showError(err, '备注保存失败'),
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
|
||||
// `['blocks']` alone left the composer's "你已将对方加入黑名单/对方已将你加入黑名单" banner (driven
|
||||
// by a SEPARATE query, `['conversation-context', topicId]` in ConversationView.tsx) showing the
|
||||
// pre-mutation state until something ELSE happened to refetch it -- live-confirmed: toggling block
|
||||
// then re-checking the same conversation immediately still showed the old banner/composer state,
|
||||
// and it only ever caught up after an incidental `refetchOnWindowFocus` (switching tabs and back).
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blocks'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['conversation-context', topicId] });
|
||||
},
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === peerUserId);
|
||||
|
||||
const deleteFriendMutation = useMutation({
|
||||
mutationFn: () => deleteFriend(peerUserId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '删除好友失败'),
|
||||
});
|
||||
|
||||
const clearHistory = async () => {
|
||||
// The old copy here ("此操作仅清除本地记录") was actively misleading -- confirmed by reading
|
||||
// the real backend handler (im_be's Topic.replyDelMsg): a soft delete (`hardDel=false`, what
|
||||
// `delMessagesAll(false)` sends) is NOT a browser-local cache wipe a refresh would undo. The
|
||||
// server records a per-account delete watermark (`t.perUser[asUid].delID`) that persists across
|
||||
// every device/session on this account and isn't reversible through any normal client action --
|
||||
// only "does it affect the other party" (no) was actually true about the old wording.
|
||||
if (
|
||||
!(await requestConfirm({
|
||||
message: '确定清空与该联系人的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响对方。',
|
||||
danger: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
const startDeleteFriend = async () => {
|
||||
if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return;
|
||||
deleteFriendMutation.mutate();
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold">聊天设置</strong>
|
||||
{onClose ? (
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
|
||||
<Avatar id={peerUserId || topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
|
||||
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
|
||||
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
|
||||
{profileQuery.data?.signature ? (
|
||||
<p className="m-0 text-center text-[13px] text-text-secondary">{profileQuery.data.signature}</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent"
|
||||
>
|
||||
<Icon name="chat_bubble_outline" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">发消息</span>
|
||||
</button>
|
||||
{isSelf ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
|
||||
}}
|
||||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="call" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">语音</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={profileQuery.data?.capabilities?.can_call === false}
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
|
||||
}}
|
||||
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
|
||||
>
|
||||
<Icon name="videocam" className="text-[20px]" />
|
||||
<span className="text-xs text-text-secondary">视频</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
|
||||
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
|
||||
<span className="text-text-body">查找聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
<div className="mt-1 grid grid-cols-4 gap-2">
|
||||
{(
|
||||
[
|
||||
{ type: 'image' as const, label: '图片', icon: 'image' },
|
||||
{ type: 'video' as const, label: '视频', icon: 'videocam' },
|
||||
{ type: 'file' as const, label: '文件', icon: 'description' },
|
||||
{ type: 'link' as const, label: '链接', icon: 'link' },
|
||||
] satisfies { type: MediaType; label: string; icon: string }[]
|
||||
).map((item) => (
|
||||
<button key={item.type} type="button" onClick={() => onOpenHistory(item.type)} className="flex flex-col items-center gap-1 py-1">
|
||||
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
|
||||
<span className="text-xs text-text-secondary">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
|
||||
{isSelf ? null : remarkEditing ? (
|
||||
<div className="flex items-center gap-2 py-3">
|
||||
<span className="flex-none text-text-body">备注</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={remarkDraft}
|
||||
onChange={(e) => setRemarkDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft)}
|
||||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||||
/>
|
||||
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft)} className="flex-none text-accent">
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRemarkDraft(friend?.remark || '');
|
||||
setRemarkEditing(true);
|
||||
}}
|
||||
className="flex items-center justify-between py-3 text-left"
|
||||
>
|
||||
<span className="text-text-body">备注</span>
|
||||
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
|
||||
{friend?.remark || '未设置'}
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">置顶聊天</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.pinned}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">消息免打扰</span>
|
||||
<Toggle
|
||||
checked={!!settingsQuery.data?.mute_notifications}
|
||||
disabled={!settingsQuery.data || updateSettings.isPending}
|
||||
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
|
||||
/>
|
||||
</div>
|
||||
{isSelf ? null : (
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-text-body">加入黑名单</span>
|
||||
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col px-5 pb-2 text-sm">
|
||||
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">清空聊天记录</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
{isSelf ? null : (
|
||||
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
|
||||
<span className="text-text-body">投诉举报</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSelf ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startDeleteFriend}
|
||||
className="mx-5 my-4 h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text"
|
||||
>
|
||||
删除好友
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
// README §3.3: "分类 Tab(最近/表情/手势/物品),12 列网格" -- the design mockup itself has no
|
||||
// specific emoji asset list, just gray placeholder tiles captioned "use system emoji or a branded
|
||||
// pack." This is a plain Unicode emoji grid (no custom art), same call the old app made (per its own
|
||||
// comment: no WeChat/QQ-style sticker assets available, system emoji is the fallback).
|
||||
const EMOJI_FACES = [
|
||||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭', '😡', '🥳', '😱', '🤯', '🥰', '😇', '🙃', '😉',
|
||||
'😆', '😅', '☺️', '😋', '😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷', '🤒', '🥴', '😪', '🙄',
|
||||
];
|
||||
const EMOJI_GESTURES = [
|
||||
'👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👌', '🤞', '👋', '👊', '✊', '🤛', '🤜', '🤟', '🤘', '👆', '👇', '👈', '👉',
|
||||
'☝️', '✋', '🤚', '🖐️', '🖖', '🤙', '💅', '🤳', '👐', '🙌', '🤲', '🫶', '🤌', '🫰', '🫵', '👋', '💃', '🕺', '🙋', '🙆',
|
||||
];
|
||||
const EMOJI_OBJECTS = [
|
||||
'❤️', '💔', '💯', '🔥', '✨', '🎉', '🎂', '☕', '🍺', '🌹', '🎁', '🎈', '⭐', '🌈', '☀️', '🌙', '⚡', '💧', '🎵', '🎮',
|
||||
'📷', '📱', '💻', '📌', '📎', '🔔', '💰', '⏰', '🔑', '💡', '📖', '✏️', '🍎', '🍕', '🍔', '🍜', '🎄', '🎃', '⚽', '🏆',
|
||||
];
|
||||
|
||||
type EmojiTab = 'recent' | 'faces' | 'gestures' | 'objects';
|
||||
|
||||
const TABS: { key: EmojiTab; label: string; list: string[] }[] = [
|
||||
{ key: 'recent', label: '最近', list: [] },
|
||||
{ key: 'faces', label: '表情', list: EMOJI_FACES },
|
||||
{ key: 'gestures', label: '手势', list: EMOJI_GESTURES },
|
||||
{ key: 'objects', label: '物品', list: EMOJI_OBJECTS },
|
||||
];
|
||||
|
||||
interface EmojiPanelProps {
|
||||
onPick: (emoji: string) => void;
|
||||
}
|
||||
|
||||
export function EmojiPanel({ onPick }: EmojiPanelProps) {
|
||||
const [tab, setTab] = useState<EmojiTab>('recent');
|
||||
const recent = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
const pick = (emoji: string) => {
|
||||
const next = [emoji, ...recent.filter((e) => e !== emoji)].slice(0, 36);
|
||||
try {
|
||||
localStorage.setItem('im-recent-emoji', JSON.stringify(next));
|
||||
} catch {
|
||||
/* storage unavailable -- recents just won't persist */
|
||||
}
|
||||
onPick(emoji);
|
||||
};
|
||||
|
||||
const list = tab === 'recent' ? recent : (TABS.find((t) => t.key === tab)?.list ?? []);
|
||||
|
||||
return (
|
||||
<div className="w-[380px] rounded-xl border border-border-panel bg-white p-2 shadow-menu">
|
||||
<div className="mb-1 flex gap-1 px-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-lg px-2.5 py-1 text-xs ${tab === t.key ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{list.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-text-tertiary-4">暂无最近使用</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-12 gap-1">
|
||||
{list.map((emoji, i) => (
|
||||
<button
|
||||
key={`${emoji}-${i}`}
|
||||
type="button"
|
||||
onClick={() => pick(emoji)}
|
||||
className="grid h-8 w-8 place-items-center rounded-lg text-lg hover:bg-surface-rail"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
|
||||
export function EmptyMain() {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
|
||||
<Icon name="forum" className="!text-5xl" />
|
||||
<span className="text-sm">选择一个会话开始聊天</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Contact } from 'tinode-sdk';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 200;
|
||||
|
||||
interface ForwardPickerModalProps {
|
||||
onPick: (targetTopics: string[], comment: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
|
||||
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
|
||||
* forward -- all three previously shared this component when it only supported picking ONE target
|
||||
* and sending immediately with no comment, meaning forwarding to several people required repeating
|
||||
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
|
||||
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
|
||||
* in one batched call -- this component only collects the selection and comment; callers send.
|
||||
*/
|
||||
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [comment, setComment] = useState('');
|
||||
// Both start expanded -- collapsing is for a long list getting in the way of the OTHER section,
|
||||
// not a default state anyone would want on first open.
|
||||
const [collapsed, setCollapsed] = useState<{ friends: boolean; groups: boolean }>({ friends: false, groups: false });
|
||||
useEscapeKey(onClose);
|
||||
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
|
||||
|
||||
// The list used to mix friends and groups in one flat run -- every other list in this app
|
||||
// (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually
|
||||
// separate, so this was the odd one out and made a specific group hard to spot among dozens of
|
||||
// P2P contacts.
|
||||
const friends = useMemo(
|
||||
() => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()),
|
||||
[filtered, tinode],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()),
|
||||
[filtered, tinode],
|
||||
);
|
||||
|
||||
const toggle = (topic: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(topic)) next.delete(topic);
|
||||
else next.add(topic);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 好友/群聊各自独立的全选 -- 一次转发常常只想覆盖其中一类(比如"发给所有群"但不想带上私聊
|
||||
// 好友),一个笼统的全选做不到这一点,所以拆成两个独立开关而不是列表整体一个。
|
||||
const friendTopics = useMemo(() => friends.map((c) => c.topic).filter((t): t is string => !!t), [friends]);
|
||||
const groupTopics = useMemo(() => groups.map((c) => c.topic).filter((t): t is string => !!t), [groups]);
|
||||
const allFriendsSelected = friendTopics.length > 0 && friendTopics.every((t) => selected.has(t));
|
||||
const allGroupsSelected = groupTopics.length > 0 && groupTopics.every((t) => selected.has(t));
|
||||
|
||||
const toggleSectionSelectAll = (topics: string[], allSelected: boolean) => {
|
||||
setSelected((prev) => {
|
||||
if (allSelected) {
|
||||
// Only drop the ones the CURRENT search actually shows -- a selection made under an earlier,
|
||||
// now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view.
|
||||
const next = new Set(prev);
|
||||
for (const t of topics) next.delete(t);
|
||||
return next;
|
||||
}
|
||||
return new Set([...prev, ...topics]);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCollapsed = (section: 'friends' | 'groups') => {
|
||||
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
const send = () => {
|
||||
if (selected.size === 0) return;
|
||||
onPick([...selected], comment.trim());
|
||||
};
|
||||
|
||||
const renderRow = (c: Contact) => {
|
||||
const isSelected = !!c.topic && selected.has(c.topic);
|
||||
return (
|
||||
<button
|
||||
key={c.topic}
|
||||
type="button"
|
||||
onClick={() => c.topic && toggle(c.topic)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(c.public?.fn)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
|
||||
<SelectionCheckbox selected={isSelected} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<button type="button" onClick={() => toggleCollapsed(section)} className="flex items-center gap-0.5 text-xs text-text-tertiary-2">
|
||||
<Icon name="chevron_right" className={`text-sm transition-transform ${collapsed[section] ? '' : 'rotate-90'}`} />
|
||||
{label}
|
||||
</button>
|
||||
<button type="button" onClick={() => toggleSectionSelectAll(topics, allSelected)} className="text-xs text-accent">
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
|
||||
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">
|
||||
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
|
||||
</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="flex-none px-4 pt-3">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索会话或联系人"
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||
{friends.length ? (
|
||||
<>
|
||||
{renderSectionHeader('好友', 'friends', friendTopics, allFriendsSelected)}
|
||||
{collapsed.friends ? null : friends.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{groups.length ? (
|
||||
<>
|
||||
{renderSectionHeader('群聊', 'groups', groupTopics, allGroupsSelected)}
|
||||
{collapsed.groups ? null : groups.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{!friends.length && !groups.length ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
|
||||
<Icon name="search_off" className="!text-4xl" />
|
||||
<span className="text-sm">未找到相关联系人</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex-none border-t border-border-list-item p-3">
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
|
||||
rows={2}
|
||||
placeholder="附言(选填)"
|
||||
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={selected.size === 0}
|
||||
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
发送{selected.size ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getAllGroupMembers } from '@/lib/api/groups';
|
||||
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
|
||||
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
import { groupMemberDisplayName } from '@/lib/messages/group-member-name';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
type Tab = 'text' | MediaType;
|
||||
|
||||
const TABS: { key: Tab; label: string; icon: string }[] = [
|
||||
{ key: 'text', label: '聊天记录', icon: 'search' },
|
||||
{ key: 'image', label: '图片', icon: 'image' },
|
||||
{ key: 'video', label: '视频', icon: 'videocam' },
|
||||
{ key: 'file', label: '文件', icon: 'description' },
|
||||
{ key: 'link', label: '链接', icon: 'link' },
|
||||
];
|
||||
|
||||
function formatDuration(ms: number | undefined): string {
|
||||
const totalSeconds = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
|
||||
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
|
||||
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
|
||||
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
|
||||
const data = item.ent?.[0]?.data;
|
||||
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
|
||||
const src = inline || remote.url;
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square overflow-hidden bg-surface-rail">
|
||||
{src ? (
|
||||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
|
||||
</div>
|
||||
)}
|
||||
{isVideo && src ? (
|
||||
<div className="absolute inset-0 bg-black/10">
|
||||
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
|
||||
{data?.duration ? (
|
||||
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
|
||||
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
|
||||
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
|
||||
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
|
||||
* through rather than picking fields back out of it by hand. The previous version's manual
|
||||
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
|
||||
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
|
||||
* search against a real plain-text message showed before this fix.
|
||||
*
|
||||
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
|
||||
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
|
||||
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
|
||||
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
|
||||
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
|
||||
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
|
||||
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
|
||||
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
|
||||
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
|
||||
* document for message bubbles. */
|
||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||
const content = item.snapshot?.content;
|
||||
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
|
||||
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
|
||||
const mobileAttachment = mobileAttachmentContentOf(asMessage);
|
||||
if (mobileAttachment) {
|
||||
const label =
|
||||
mobileAttachment.kind === 'image'
|
||||
? '[图片]'
|
||||
: mobileAttachment.kind === 'video'
|
||||
? '[视频]'
|
||||
: mobileAttachment.kind === 'voice'
|
||||
? '[语音]'
|
||||
: `[文件] ${mobileAttachment.name || ''}`;
|
||||
return <span className="text-text-body">{label}</span>;
|
||||
}
|
||||
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
|
||||
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
|
||||
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
|
||||
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
|
||||
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
|
||||
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
|
||||
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
|
||||
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
|
||||
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
|
||||
}
|
||||
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
|
||||
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
|
||||
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
|
||||
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
|
||||
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
|
||||
* whose inline links aren't gated here (same as any other chat-bubble link).
|
||||
*/
|
||||
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
|
||||
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
|
||||
|
||||
if (!item.url) return <SnapshotContent item={item} />;
|
||||
|
||||
const open = async () => {
|
||||
let result;
|
||||
try {
|
||||
result = await safetyCheck.mutateAsync(item.url!);
|
||||
} catch {
|
||||
notify('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
notify('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
|
||||
window.open(item.url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
|
||||
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
|
||||
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={open}
|
||||
disabled={safetyCheck.isPending}
|
||||
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{item.url}
|
||||
</button>
|
||||
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
|
||||
{loading ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface HistorySearchModalProps {
|
||||
topicId: string;
|
||||
initialTab?: Tab;
|
||||
onClose: () => void;
|
||||
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
|
||||
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
|
||||
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
|
||||
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
}
|
||||
|
||||
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
|
||||
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
useEscapeKey(onClose);
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId),
|
||||
enabled: isGroup,
|
||||
});
|
||||
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
|
||||
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
|
||||
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
|
||||
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
|
||||
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
|
||||
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
|
||||
// prefix removal, not a lossy truncation.
|
||||
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
|
||||
const senderLabel = (from: string): string => {
|
||||
const full = normalizeSender(from);
|
||||
if (myUserId && full === myUserId) return '我';
|
||||
if (isGroup) {
|
||||
const member = membersQuery.data?.find((m) => m.user_id === full);
|
||||
return groupSenderLabel(member && groupMemberDisplayName(member));
|
||||
}
|
||||
return peerName;
|
||||
};
|
||||
|
||||
const searchQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search', topicId, debouncedQuery],
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
|
||||
});
|
||||
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const mediaQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-media', topicId, tab],
|
||||
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: tab === 'image' || tab === 'video',
|
||||
});
|
||||
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
const fileLinkQuery = useInfiniteQuery({
|
||||
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
|
||||
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
|
||||
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
|
||||
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
|
||||
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
|
||||
// header element sitting above all four tabs, not a 消息-tab-only control.
|
||||
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
enabled: tab === 'file' || tab === 'link',
|
||||
});
|
||||
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
|
||||
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
|
||||
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
|
||||
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
|
||||
// hands off the target and gets out of the way.
|
||||
const jumpToMessage = (seq: number) => {
|
||||
useMessageJumpStore.getState().requestJump(topicId, seq);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
|
||||
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
|
||||
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
|
||||
// exactly as before.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
|
||||
<div
|
||||
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</header>
|
||||
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
|
||||
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
|
||||
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
{query ? (
|
||||
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
|
||||
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
|
||||
取消
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
|
||||
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={t.icon} className="text-[16px]" />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-none px-5 py-3 md:block">
|
||||
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
|
||||
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
|
||||
disabled={tab === 'image' || tab === 'video'}
|
||||
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'text' ? (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||
{!debouncedQuery.trim() ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
|
||||
) : searchQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>搜索失败,请重试</span>
|
||||
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !searchItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="flex flex-col gap-1">
|
||||
{searchItems.map((item) => {
|
||||
const fullFrom = normalizeSender(item.from);
|
||||
const name = senderLabel(item.from);
|
||||
return (
|
||||
<li key={item.seq}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jumpToMessage(item.seq)}
|
||||
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
|
||||
>
|
||||
<span
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
|
||||
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
|
||||
}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
|
||||
<span className="truncate text-text-tertiary-2">{name}</span>
|
||||
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{searchQuery.hasNextPage ? (
|
||||
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : tab === 'image' || tab === 'video' ? (
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{mediaQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : mediaQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !mediaItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
|
||||
{mediaItems.map((item) => (
|
||||
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
|
||||
))}
|
||||
</div>
|
||||
{mediaQuery.hasNextPage ? (
|
||||
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{fileLinkQuery.isLoading ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : fileLinkQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : !fileLinkItems.length ? (
|
||||
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{fileLinkItems.map((item) =>
|
||||
tab === 'link' ? (
|
||||
<LinkResult key={item.seq} item={item} />
|
||||
) : (
|
||||
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{fileLinkQuery.hasNextPage ? (
|
||||
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { getGroupMembers, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useDebouncedValue } from '@/lib/hooks';
|
||||
import { MENTION_ALL_SENTINEL, type PendingMention } from '@/lib/messages/mentions';
|
||||
|
||||
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
|
||||
|
||||
interface MentionPickerProps {
|
||||
topicId: string;
|
||||
query: string;
|
||||
onPick: (choice: PendingMention) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设计稿 4.3:输入 `@` 触发的 260px 浮层。候选来自 `GET .../members?query=&mentionable=true`
|
||||
* (协议文档 1052/1735 行)-- 这一个响应同时带 `items`(已经是可提及成员,不用再拿全量列表自己过滤)
|
||||
* 和 `permissions.can_mention_all`,比向 ConversationView 另外要这两样数据都便宜。"所有人"是虚拟项,
|
||||
* 不对应真实成员,只在 `can_mention_all` 为真时出现在首位。
|
||||
*/
|
||||
export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
|
||||
const debouncedQuery = useDebouncedValue(query, 200);
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-mentionable-members', topicId, debouncedQuery],
|
||||
queryFn: () => getGroupMembers(topicId, { query: debouncedQuery, mentionable: true }),
|
||||
});
|
||||
const items = membersQuery.data?.items || [];
|
||||
const canMentionAll = !!membersQuery.data?.permissions?.can_mention_all;
|
||||
const showAll = canMentionAll && (!query || '所有人'.includes(query));
|
||||
|
||||
if (!showAll && items.length === 0 && !membersQuery.isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="w-[260px] overflow-hidden rounded-[10px] border border-border-panel bg-white py-1 shadow-menu">
|
||||
{membersQuery.isLoading ? (
|
||||
<div className="px-3 py-4 text-center text-xs text-text-tertiary-4">加载中…</div>
|
||||
) : (
|
||||
<>
|
||||
{showAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: MENTION_ALL_SENTINEL, label: '所有人' })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-accent text-white">
|
||||
<Icon name="group" className="text-[16px]" />
|
||||
</span>
|
||||
<span className="text-sm text-text-body">所有人</span>
|
||||
</button>
|
||||
) : null}
|
||||
{items.map((m) => {
|
||||
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
|
||||
// private remark for this member (see GroupMember.display_name's own doc comment) --
|
||||
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
|
||||
// into the message's shared content once and read by every OTHER member from then on,
|
||||
// who have no idea what the sender's personal remark for this person means. `nickname`
|
||||
// is that member's own real public name, unaffected by anyone's remark, so it stays
|
||||
// correct for whoever else ends up reading the mention.
|
||||
const label = m.group_nickname || m.nickname || m.display_name || m.user_id;
|
||||
return (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: m.user_id, label })}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
|
||||
>
|
||||
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
|
||||
{avatarLetter(label)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
|
||||
{m.role !== 'member' ? (
|
||||
<span
|
||||
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
|
||||
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
|
||||
}`}
|
||||
>
|
||||
{ROLE_LABEL[m.role]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { StoredMessage, Topic } from 'tinode-sdk';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
|
||||
import {
|
||||
callResultDisplay,
|
||||
contactCardContentOf,
|
||||
isRecalled,
|
||||
isServerDeleted,
|
||||
mobileAttachmentContentOf,
|
||||
systemNoticeText,
|
||||
type ContactCardContent,
|
||||
} from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
|
||||
import { QuoteBar } from '@/features/messages/QuoteBar';
|
||||
import { parseReplyHead } from '@/lib/messages/reply';
|
||||
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
|
||||
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
|
||||
import { groupSenderLabel } from '@/lib/messages/sender-name';
|
||||
import { retryMessage } from '@/lib/tinode/send';
|
||||
import { ensureTopicAttached } from '@/lib/tinode/topics';
|
||||
import { openUserCard } from '@/stores/userCard';
|
||||
|
||||
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
|
||||
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
|
||||
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
|
||||
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
|
||||
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
|
||||
* the real profile page, the one thing a contact-card message is fundamentally for. */
|
||||
function ContactCardBubble({ content, locked }: { content: ContactCardContent; locked?: boolean }) {
|
||||
const name = content.display_name || content.public_account || content.user_id;
|
||||
const inner = (
|
||||
<>
|
||||
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-text-body">{name}</span>
|
||||
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
|
||||
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
|
||||
if (locked) {
|
||||
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
|
||||
}
|
||||
return (
|
||||
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
|
||||
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
|
||||
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MessageRowProps {
|
||||
topic: Topic;
|
||||
msg: StoredMessage;
|
||||
mine: boolean;
|
||||
isGroup: boolean;
|
||||
peerName: string;
|
||||
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
|
||||
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
|
||||
* for per-message attribution once there's more than one possible sender. */
|
||||
senderName?: string;
|
||||
senderRole?: 'owner' | 'admin' | 'member';
|
||||
/** The avatar to show for whoever sent THIS row -- resolved by ConversationView per-message (its
|
||||
* own `avatar_file_id`, the group member's, or the peer's, depending on `mine`/`isGroup`), not a
|
||||
* single fixed prop like `peerName`, since every row in a group can have a different sender. */
|
||||
avatarFileId?: string;
|
||||
selectMode?: boolean;
|
||||
selected?: boolean;
|
||||
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
|
||||
* context menu's target. */
|
||||
menuActive?: boolean;
|
||||
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
|
||||
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
|
||||
* open outline, since the two states mean different things (context menu open vs. "you asked to find
|
||||
* this one") and can't reuse the same visual without conflating them. */
|
||||
highlighted?: boolean;
|
||||
onToggleSelect?: (seq: number) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
|
||||
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
|
||||
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
|
||||
* must never print a raw uid. */
|
||||
resolveQuotedName?: (uid: string | undefined) => string;
|
||||
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
|
||||
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
|
||||
* even made. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function MessageRow({
|
||||
topic,
|
||||
msg,
|
||||
mine,
|
||||
isGroup,
|
||||
peerName,
|
||||
senderName,
|
||||
senderRole,
|
||||
avatarFileId,
|
||||
selectMode,
|
||||
selected,
|
||||
menuActive,
|
||||
highlighted,
|
||||
onToggleSelect,
|
||||
onContextMenu,
|
||||
resolveQuotedName,
|
||||
onError,
|
||||
}: MessageRowProps) {
|
||||
if (isServerDeleted(msg)) {
|
||||
return (
|
||||
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
|
||||
此消息已被删除
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noticeText = systemNoticeText(msg);
|
||||
if (noticeText) {
|
||||
return (
|
||||
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
|
||||
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
|
||||
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const failed = isFailedStatus(topic, msg);
|
||||
const sending = isSendingStatus(topic, msg);
|
||||
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
|
||||
const callResult = callResultDisplay(msg, mine);
|
||||
const contactCard = contactCardContentOf(msg);
|
||||
const mobileAttachment = mobileAttachmentContentOf(msg);
|
||||
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
|
||||
// collapses to the "消息已删除" placeholder, quote bar included.
|
||||
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
|
||||
|
||||
const retry = () => {
|
||||
if (typeof msg.seq !== 'number') return;
|
||||
const seq = msg.seq;
|
||||
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
|
||||
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
|
||||
// exact same way again.
|
||||
ensureTopicAttached(topic.name)
|
||||
.then(() => retryMessage(topic, seq))
|
||||
.catch(() => onError?.('重试发送失败'));
|
||||
};
|
||||
|
||||
// In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user
|
||||
// that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled
|
||||
// message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this
|
||||
// guards the same way.
|
||||
const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number';
|
||||
const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-seq={msg.seq}
|
||||
onClick={toggleThisRow}
|
||||
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
|
||||
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
|
||||
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
|
||||
// row over, while still refusing to let it join a batch action.
|
||||
isRecalled(msg) ? (
|
||||
<div className="h-5 w-5 flex-none" />
|
||||
) : (
|
||||
// No onClick of its own -- the row-level handler above already toggles it, and a second
|
||||
// handler here would double-toggle on every click (select then immediately un-select).
|
||||
<div className="flex-none">
|
||||
<SelectionCheckbox selected={selected} />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
<div
|
||||
onContextMenu={(e) => {
|
||||
if (selectMode) return;
|
||||
e.preventDefault();
|
||||
onContextMenu?.(e, msg);
|
||||
}}
|
||||
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
{/* Group members' avatar/name go to their profile (same destination ContactCardBubble's own
|
||||
card already uses, which works for a non-friend group member too -- FriendProfileView's
|
||||
relationship-aware buttons handle that case, it isn't friends-only). Not for `mine` -- that
|
||||
slot always reads "我", and viewing "your own profile" via a route built around a two-party
|
||||
relationship isn't a case this app has ever needed to support. Not during `selectMode`
|
||||
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
|
||||
not navigate away and lose whatever's already been picked. */}
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
|
||||
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<Avatar
|
||||
id={msg.from || (isGroup ? '' : peerName)}
|
||||
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
|
||||
avatarFileId={avatarFileId}
|
||||
fallbackBgClass={mine ? 'bg-accent' : undefined}
|
||||
className="h-9 w-9 flex-none rounded-lg text-sm"
|
||||
/>
|
||||
)}
|
||||
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
|
||||
{isGroup ? (
|
||||
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
|
||||
{!mine && !selectMode && msg.from ? (
|
||||
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
|
||||
{groupSenderLabel(senderName)}
|
||||
</button>
|
||||
) : (
|
||||
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
|
||||
)}
|
||||
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
|
||||
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
|
||||
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
|
||||
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
|
||||
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : null}
|
||||
<div
|
||||
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
|
||||
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
|
||||
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
|
||||
>
|
||||
{replyInfo ? (
|
||||
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
|
||||
) : null}
|
||||
{isRecalled(msg) ? (
|
||||
'⚠ 消息已删除'
|
||||
) : callResult ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
|
||||
{callResult.text}
|
||||
</span>
|
||||
) : contactCard ? (
|
||||
<ContactCardBubble content={contactCard} locked={selectMode} />
|
||||
) : mobileAttachment ? (
|
||||
mobileAttachment.kind === 'image' ? (
|
||||
<ImageAttachment data={mobileAttachment} locked={selectMode} />
|
||||
) : mobileAttachment.kind === 'video' ? (
|
||||
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
|
||||
) : mobileAttachment.kind === 'voice' ? (
|
||||
<AudioAttachment data={mobileAttachment} locked={selectMode} />
|
||||
) : (
|
||||
<FileAttachment data={mobileAttachment} locked={selectMode} />
|
||||
)
|
||||
) : (
|
||||
renderDrafty(msg.content, undefined, !selectMode)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{failed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retry();
|
||||
}}
|
||||
className="text-xs text-unread"
|
||||
>
|
||||
发送失败,点击重试
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-tertiary-3">{sending ? '发送中…' : statusLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import type { MessageAction } from '@/lib/messages/actions';
|
||||
|
||||
interface MenuItem {
|
||||
action: MessageAction;
|
||||
label: string;
|
||||
icon: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const ITEMS: Record<MessageAction, MenuItem> = {
|
||||
quote: { action: 'quote', label: '引用', icon: 'format_quote' },
|
||||
copy: { action: 'copy', label: '复制', icon: 'content_copy' },
|
||||
favorite: { action: 'favorite', label: '收藏', icon: 'inventory_2' },
|
||||
forward: { action: 'forward', label: '转发', icon: 'reply' },
|
||||
recall: { action: 'recall', label: '撤回', icon: 'undo' },
|
||||
select: { action: 'select', label: '多选', icon: 'select_all' },
|
||||
delete: { action: 'delete', label: '删除', icon: 'delete_outline', danger: true },
|
||||
};
|
||||
|
||||
interface MessageContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
actions: MessageAction[];
|
||||
/** README §3.5: "撤回...右侧灰字剩余时限「1:42 内」" -- only meaningful when `actions` includes 'recall'. */
|
||||
recallRemainingSeconds?: number | null;
|
||||
onAction: (action: MessageAction) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatRecallCountdown(seconds: number): string {
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')} 内`;
|
||||
}
|
||||
|
||||
/** README §3.5: 180px popup, divider before the destructive-leaning tail (recall/delete). */
|
||||
export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAction, onClose }: MessageContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent | MouseEvent | Event) => {
|
||||
if (e instanceof KeyboardEvent && e.key !== 'Escape') return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener('click', handler);
|
||||
window.addEventListener('scroll', handler, true);
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => {
|
||||
window.removeEventListener('click', handler);
|
||||
window.removeEventListener('scroll', handler, true);
|
||||
window.removeEventListener('keydown', handler);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
// Right-clicking near the window's right/bottom edge would otherwise render this partially
|
||||
// off-screen -- clamp against the menu's actual measured size once it's in the DOM.
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const left = Math.min(x, window.innerWidth - rect.width - 8);
|
||||
const top = Math.min(y, window.innerHeight - rect.height - 8);
|
||||
setPos({ left: Math.max(8, left), top: Math.max(8, top) });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [x, y]);
|
||||
|
||||
const leading = actions.filter((a) => a !== 'recall' && a !== 'delete');
|
||||
const trailing = actions.filter((a) => a === 'recall' || a === 'delete');
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={pos}
|
||||
className="fixed z-50 w-[180px] rounded-[10px] border border-border-panel bg-white p-1.5 shadow-menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{leading.map((action) => {
|
||||
const item = ITEMS[action];
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => onAction(action)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm text-text-secondary hover:bg-surface-rail"
|
||||
>
|
||||
<Icon name={item.icon} className="text-[18px]" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
|
||||
{trailing.map((action) => {
|
||||
const item = ITEMS[action];
|
||||
return (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => onAction(action)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left text-sm hover:bg-surface-rail ${
|
||||
item.danger ? 'text-danger-text' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="text-[18px]" />
|
||||
{item.label}
|
||||
{action === 'recall' && typeof recallRemainingSeconds === 'number' ? (
|
||||
<span className="ml-auto text-xs text-text-tertiary-3">{formatRecallCountdown(recallRemainingSeconds)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Outlet, useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
import { ChatListColumn } from '@/features/messages/ChatListColumn';
|
||||
import { DetailColumn } from '@/features/messages/DetailColumn';
|
||||
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
|
||||
import { OfflineBanner } from '@/features/messages/OfflineBanner';
|
||||
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
import { useChatList, useTinode } from '@/lib/tinode/hooks';
|
||||
|
||||
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
|
||||
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
|
||||
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
|
||||
* read on every render. */
|
||||
function ConversationDetailPanel({
|
||||
isGroup,
|
||||
topicId,
|
||||
peerName,
|
||||
onClose,
|
||||
onOpenHistory,
|
||||
}: {
|
||||
isGroup: boolean;
|
||||
topicId: string;
|
||||
peerName: string;
|
||||
onClose?: () => void;
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}) {
|
||||
return isGroup ? (
|
||||
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
||||
) : (
|
||||
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
|
||||
);
|
||||
}
|
||||
|
||||
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
|
||||
|
||||
function readPersistedDetailPanelOpen(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The 4-tier responsive split described in the design handoff:
|
||||
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
|
||||
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
|
||||
* provide one at this tier -- see ConversationView).
|
||||
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
|
||||
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
|
||||
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
|
||||
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
|
||||
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
|
||||
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
|
||||
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
|
||||
* affordance at all).
|
||||
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
|
||||
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
|
||||
* to dock or drawer it in without covering the conversation entirely anyway.
|
||||
*/
|
||||
export function MessagesLayout() {
|
||||
const { topicId } = useParams();
|
||||
const location = useLocation();
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
|
||||
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
|
||||
const isDocked = useMediaQuery('(min-width: 1280px)');
|
||||
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
|
||||
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
|
||||
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
|
||||
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
|
||||
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
|
||||
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
|
||||
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
|
||||
// and effects for no visible reason, and briefly having two real instances of the same panel alive
|
||||
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
|
||||
// that isn't safe to run twice.
|
||||
const isMdUp = useMediaQuery('(min-width: 960px)');
|
||||
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
|
||||
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
|
||||
// to the same list-only view as the bare `/messages` index at narrow widths.
|
||||
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
|
||||
const contacts = useChatList();
|
||||
const tinode = useTinode();
|
||||
// `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the
|
||||
// lookup below always misses for it -- same gap ConversationView.tsx's own header already had and
|
||||
// fixed the same way, just computed independently here for this panel's title/detail-column
|
||||
// props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has
|
||||
// already mounted it, not a second request.
|
||||
const isSelf = topicId === 'slf';
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
|
||||
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
|
||||
|
||||
const toggleDetail = () => {
|
||||
if (isDocked) {
|
||||
setDetailPanelOpen((v) => {
|
||||
const next = !v;
|
||||
try {
|
||||
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
|
||||
} catch {
|
||||
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
|
||||
// toggle itself still works for the rest of this session.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setDetailOpen((v) => !v);
|
||||
}
|
||||
};
|
||||
|
||||
// MessagesLayout is one long-lived instance reused across every conversation (route is
|
||||
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
|
||||
// showing results for whichever topic it was opened against, not the one now on screen.
|
||||
useLayoutEffect(() => setHistoryTab(null), [topicId]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full">
|
||||
<div className="relative flex h-full min-w-0 flex-1">
|
||||
<div className="absolute inset-x-0 top-0 z-10">
|
||||
<OfflineBanner />
|
||||
</div>
|
||||
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
|
||||
<ChatListColumn />
|
||||
</div>
|
||||
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
|
||||
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
|
||||
</div>
|
||||
</div>
|
||||
{isDocked && detailPanelOpen && topicId ? (
|
||||
<div className="hidden h-full lg:block">
|
||||
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
|
||||
</div>
|
||||
) : null}
|
||||
{!isDocked && isMdUp && detailOpen && topicId ? (
|
||||
<div className="absolute inset-0 hidden md:block lg:hidden">
|
||||
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
|
||||
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
|
||||
<ConversationDetailPanel
|
||||
isGroup={isGroup}
|
||||
topicId={topicId}
|
||||
peerName={peerName}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onOpenHistory={setHistoryTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!isDocked && !isMdUp && detailOpen && topicId ? (
|
||||
<div className="fixed inset-0 z-30 bg-white md:hidden">
|
||||
<ConversationDetailPanel
|
||||
isGroup={isGroup}
|
||||
topicId={topicId}
|
||||
peerName={peerName}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onOpenHistory={setHistoryTab}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{historyTab && topicId ? (
|
||||
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { retryReconnectNow } from '@/lib/tinode/connection';
|
||||
import { useReconnectState } from '@/lib/tinode/hooks';
|
||||
|
||||
/**
|
||||
* README §3.7's top-banner states, overlaying ChatList+Main. Only one shows at a time, priority:
|
||||
* offline > reconnecting > just-reconnected (most to least urgent). "已重新连接,正在同步 N 条新
|
||||
* 消息" is shown without a fake N -- the old app never implemented this state at all (confirmed by
|
||||
* research), and there's no reliable signal here for "how many messages arrived while disconnected,"
|
||||
* so inventing a number would just be dishonest data.
|
||||
*/
|
||||
export function OfflineBanner() {
|
||||
const reconnect = useReconnectState();
|
||||
|
||||
if (!reconnect.networkOnline) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center gap-2 bg-error-strip-bg px-4 text-[13px] text-error-strip-text">
|
||||
<Icon name="wifi_off" className="text-[16px]" />
|
||||
当前无网络连接,消息将在恢复后自动发送
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reconnect.retryInSeconds > 0 || reconnect.attemptInFlight) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center justify-between bg-announce-bg px-4 text-[13px] text-announce-text">
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon name="autorenew" className="animate-spin text-[16px] text-announce" />
|
||||
{reconnect.attemptInFlight ? '连接已断开,正在重连…' : `连接已断开,正在重连(${reconnect.retryInSeconds} 秒后重试)…`}
|
||||
</span>
|
||||
{!reconnect.attemptInFlight ? (
|
||||
<button type="button" onClick={retryReconnectNow} className="text-accent">
|
||||
立即重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reconnect.justReconnected) {
|
||||
return (
|
||||
<div className="flex h-10 flex-none items-center gap-2 bg-info-strip-bg px-4 text-[13px] text-info-strip-text">
|
||||
<Icon name="sync" className="text-[16px] text-accent" />
|
||||
已重新连接,正在同步消息…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
|
||||
import {
|
||||
placeholderForType,
|
||||
quotedMediaOf,
|
||||
type ReplyInfo,
|
||||
type QuoteMedia,
|
||||
} from '@/lib/messages/reply';
|
||||
import { useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
|
||||
/**
|
||||
* Quote bar rendered at the top of a bubble, driven exclusively by `head["x-im-reply"]`
|
||||
* (cross-client contract aligned with iOS, 2026-09-23):
|
||||
*
|
||||
* - the head only carries a lightweight snapshot {seq,type,text,from}; the quoted message itself
|
||||
* is re-resolved from the LOCAL message cache by seq, which unlocks real thumbnails / voice
|
||||
* durations and the "该消息已删除" state that no snapshot can express;
|
||||
* - when the quoted message isn't local (fresh device, cleared history, unloaded range), the
|
||||
* snapshot carries rendering alone -- localized placeholder per type, never blank;
|
||||
* - clicking requests a scroll-locate + highlight via the same messageJump store the history
|
||||
* search uses; it transparently fetches the surrounding range when the target isn't loaded.
|
||||
*
|
||||
* Deliberately a plain <button>-free clickable div: it must not submit anything, but must allow
|
||||
* nested long-press/selection behavior of the bubble itself.
|
||||
*/
|
||||
|
||||
function formatVoiceDuration(ms: number | undefined): string {
|
||||
if (typeof ms !== 'number' || ms <= 0) return '';
|
||||
return `${Math.max(1, Math.round(ms / 1000))}″`;
|
||||
}
|
||||
|
||||
/** Small authenticated thumbnail (28x28 box per iOS spec) for image/video quotes. */
|
||||
function QuoteThumb({ media }: { media: { val?: string; ref?: string; mime?: string } }) {
|
||||
const inline = typeof media.val === 'string' ? `data:${media.mime || 'image/jpeg'};base64,${media.val}` : null;
|
||||
const remote = useAuthedBlobUrl(inline ? undefined : media.ref);
|
||||
const src = inline || remote.url;
|
||||
if (!src) {
|
||||
return (
|
||||
<span className="grid h-7 w-7 flex-none place-items-center rounded bg-black/[0.06]">
|
||||
<Icon name="image" className="text-[15px] text-text-tertiary-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img src={src} alt="" className="h-7 w-7 flex-none rounded object-cover" />;
|
||||
}
|
||||
|
||||
function MediaSummary({ media }: { media: QuoteMedia }) {
|
||||
if ((media.kind === 'image' || media.kind === 'video') && media.image) {
|
||||
return (
|
||||
<span className="flex min-w-0 flex-none items-center gap-1">
|
||||
{media.kind === 'video' ? <Icon name="play_circle" className="flex-none text-[14px]" /> : null}
|
||||
<QuoteThumb media={media.image} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (media.kind === 'voice') {
|
||||
const seconds = formatVoiceDuration(media.durationMs);
|
||||
return (
|
||||
<span className="flex flex-none items-center gap-1">
|
||||
<Icon name="mic_none" className="text-[14px]" />
|
||||
{seconds ? <span>{seconds}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <Icon name="insert_drive_file" className="flex-none text-[14px]" />;
|
||||
}
|
||||
|
||||
interface QuoteBarProps {
|
||||
topicName: string;
|
||||
reply: ReplyInfo;
|
||||
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
|
||||
* already owns this lookup, and must never surface a raw uid to the user. */
|
||||
resolveName: (uid: string | undefined) => string;
|
||||
/** MessageBubble's selectMode: a tap here must only toggle the row's own selection (same reasoning
|
||||
* as the avatar/sender-name buttons going inert during bulk-select), not also jump to the quoted
|
||||
* message -- so `locate` is left unwired and this renders as a plain, non-interactive div instead
|
||||
* of `role="button"`. */
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteBar({ topicName, reply, resolveName, locked }: QuoteBarProps) {
|
||||
const messages = useTopicMessages(topicName);
|
||||
const quoted: StoredMessage | undefined = useMemo(
|
||||
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
|
||||
[messages, reply.seq],
|
||||
);
|
||||
|
||||
const deleted = !!quoted && (isRecalled(quoted) || isServerDeleted(quoted));
|
||||
// Local media is only usable when the quoted message is present AND alive -- a recalled message's
|
||||
// thumbnail must not leak back through the quote bar (the snapshot is the only legal residue).
|
||||
const localMedia = quoted && !deleted ? quotedMediaOf(quoted) : null;
|
||||
const name = resolveName(quoted?.from || reply.from);
|
||||
|
||||
// Per the cross-client spec, only the media/card-like types render as placeholders; any unknown
|
||||
// type is rendered as text using the snapshot body.
|
||||
const MEDIA_LIKE_TYPES = new Set(['image', 'video', 'voice', 'file', 'contact_card', 'forward_bundle', 'sticker']);
|
||||
const isTextLike = !MEDIA_LIKE_TYPES.has(reply.type);
|
||||
// Real media affordances only when the local copy actually carries what they need: a usable image
|
||||
// ref for image/video, a duration for voice. Otherwise fall back to the snapshot placeholder
|
||||
// (exactly the iOS "取不到缩略图 → 名字:[图片]" / "取不到 → 名字:[语音]" behavior).
|
||||
const hasThumb = !!localMedia && (localMedia.kind === 'image' || localMedia.kind === 'video') && !!localMedia.image;
|
||||
const hasVoiceDuration = localMedia?.kind === 'voice' && typeof localMedia.durationMs === 'number' && localMedia.durationMs > 0;
|
||||
|
||||
const locate = () => {
|
||||
useMessageJumpStore.getState().requestJump(topicName, reply.seq);
|
||||
};
|
||||
|
||||
if (deleted) {
|
||||
return (
|
||||
<div
|
||||
role={locked ? undefined : 'button'}
|
||||
tabIndex={locked ? undefined : 0}
|
||||
onClick={locked ? undefined : locate}
|
||||
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
|
||||
>
|
||||
该消息已删除
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={locked ? undefined : 'button'}
|
||||
tabIndex={locked ? undefined : 0}
|
||||
onClick={locked ? undefined : locate}
|
||||
title={locked ? undefined : '查看原消息'}
|
||||
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
|
||||
>
|
||||
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
|
||||
{hasThumb || hasVoiceDuration ? <MediaSummary media={localMedia as QuoteMedia} /> : null}
|
||||
{isTextLike ? (
|
||||
<span className="line-clamp-2 min-w-0 break-words">{reply.text}</span>
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{reply.text || placeholderForType(reply.type)}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { checkUrlSafety } from '@/lib/api/security';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notify } from '@/stores/globalToast';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
|
||||
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
|
||||
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
|
||||
* Same UX contract as HistorySearchModal.tsx's own (dead-code, see its comment) LinkResult -- this is
|
||||
* the renderer (lib/messages/drafty.tsx's `LN` case) that component itself falls back to for a hit
|
||||
* that doesn't carry its own `url` enrichment field, so fixing it here covers both call sites: this
|
||||
* was previously the only actually-reachable one, since SearchHit's real response has no `url` field.
|
||||
*/
|
||||
export function SafeLink({ href, children }: { href: string | undefined; children: ReactNode }) {
|
||||
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'chat_message') });
|
||||
if (!href) return <span className="text-accent underline">{children}</span>;
|
||||
|
||||
const open = async () => {
|
||||
let result;
|
||||
try {
|
||||
result = await safetyCheck.mutateAsync(href);
|
||||
} catch {
|
||||
notify('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
notify('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
|
||||
window.open(href, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
return (
|
||||
<button type="button" onClick={open} disabled={safetyCheck.isPending} className="text-accent underline disabled:opacity-60">
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { MAX_DURATION, MIN_DURATION } from '@/lib/config';
|
||||
|
||||
// Chrome/Firefox vs Safari support different codecs for MediaRecorder -- ported from the old app's
|
||||
// audio-recorder.jsx, first-supported-wins probe.
|
||||
const AUDIO_MIME_TYPES = ['audio/webm', 'audio/mp4', ''];
|
||||
|
||||
function formatSeconds(s: number): string {
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSend: (blob: Blob, durationMs: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.3's recording state: click-to-start (from the composer's mic icon), explicit 取消/发送
|
||||
* buttons -- not the old app's press-and-hold-with-swipe-to-cancel gesture, which the new design
|
||||
* replaces with plain buttons (a reasonable desktop-first adaptation; the underlying recording
|
||||
* mechanics are the same). Silently cancels (no message sent) if the recording is shorter than
|
||||
* MIN_DURATION, matching the old app's "too short, discard" behavior.
|
||||
*/
|
||||
export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const startedAtRef = useRef(0);
|
||||
const finishedRef = useRef(false);
|
||||
|
||||
const stopStream = () => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
const finish = (send: boolean) => {
|
||||
if (finishedRef.current) return;
|
||||
finishedRef.current = true;
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder || recorder.state === 'inactive') {
|
||||
stopStream();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
const durationMs = Date.now() - startedAtRef.current;
|
||||
recorder.onstop = () => {
|
||||
stopStream();
|
||||
if (send && durationMs >= MIN_DURATION) {
|
||||
onSend(new Blob(chunksRef.current, { type: recorder.mimeType }), durationMs);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
recorder.stop();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
const mimeType = AUDIO_MIME_TYPES.find((t) => MediaRecorder.isTypeSupported(t));
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType, audioBitsPerSecond: 24_000 } : undefined);
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
// Without this, a mid-recording failure (mic unplugged, OS audio error) auto-transitions
|
||||
// the recorder to 'inactive' with nothing telling the UI -- the "录音中" timer just keeps
|
||||
// ticking forever with no way to know the recording already died until the user manually
|
||||
// hits 取消/发送 and finish()'s own `recorder.state === 'inactive'` check silently no-ops.
|
||||
recorder.onerror = () => {
|
||||
if (finishedRef.current) return;
|
||||
finishedRef.current = true;
|
||||
stopStream();
|
||||
setError('录音失败,请重试');
|
||||
};
|
||||
recorder.start();
|
||||
recorderRef.current = recorder;
|
||||
startedAtRef.current = Date.now();
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => setError('无法访问麦克风,请检查权限设置'));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopStream();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return undefined;
|
||||
const timer = window.setInterval(() => {
|
||||
setSeconds((s) => {
|
||||
const next = s + 1;
|
||||
if (next * 1000 >= MAX_DURATION) finish(true);
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between rounded-lg bg-error-strip-bg px-4 text-sm text-error-strip-text">
|
||||
{error}
|
||||
<button type="button" onClick={onCancel} className="text-accent">
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
|
||||
<span className="flex flex-none items-center gap-2 text-sm font-medium text-text-body">
|
||||
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-unread" />
|
||||
录音中 {formatSeconds(seconds)}
|
||||
</span>
|
||||
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
|
||||
<div className="flex flex-none items-center gap-4">
|
||||
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => finish(true)}
|
||||
disabled={!ready}
|
||||
className="flex h-8 items-center gap-1 rounded-lg bg-btn px-4 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled"
|
||||
>
|
||||
<Icon name="send" className="text-[16px]" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface FriendAvatarSource {
|
||||
user_id: string;
|
||||
topic?: string;
|
||||
avatar_file_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P contacts are keyed by their `p2p...` topic after the first subscription,
|
||||
* while the friends API identifies the same person with a `usr...` user ID.
|
||||
* Keep both aliases so avatars work during both the initial and established
|
||||
* conversation states.
|
||||
*/
|
||||
export function friendAvatarLookup(friends: FriendAvatarSource[] = []) {
|
||||
const avatars = new Map<string, string | undefined>();
|
||||
for (const friend of friends) {
|
||||
if (friend.user_id) avatars.set(friend.user_id, friend.avatar_file_id);
|
||||
if (friend.topic) avatars.set(friend.topic, friend.avatar_file_id);
|
||||
}
|
||||
return avatars;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
|
||||
/**
|
||||
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
|
||||
* all (docs/IM-后端问题清单.md-adjacent research: "No backend endpoint clears cache/history -- both
|
||||
* are pure client-side operations"). Computing a real, honest "just this app's cache" byte count is
|
||||
* not reliably possible (`navigator.storage.estimate()` reports the WHOLE origin, not a
|
||||
* per-feature breakdown) -- rather than show a fabricated-looking number, this omits the size and
|
||||
* just performs and confirms the real clear (this app's own Cache Storage buckets, per the SW-
|
||||
* caching behavior already relied on elsewhere in this app).
|
||||
*/
|
||||
export function GeneralSettingsPage() {
|
||||
const [status, setStatus] = useState<'idle' | 'clearing' | 'done'>('idle');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return;
|
||||
setStatus('clearing');
|
||||
try {
|
||||
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((k) => caches.delete(k)));
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
setStatus('idle');
|
||||
setError(err instanceof Error ? err.message : '清除缓存失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">通用</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<button type="button" onClick={clearCache} disabled={status === 'clearing'} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||
<span className="text-text-body">清空缓存</span>
|
||||
<span className="text-text-tertiary-2">{status === 'clearing' ? '清除中…' : status === 'done' ? '已清除 ›' : '›'}</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* README §8.1's directory lists a dedicated "消息通知" section, but there is no per-type
|
||||
* notification-preference REST endpoint anywhere (confirmed: grepped both protocol-doc copies for
|
||||
* notification_settings/推送偏好, zero hits; the old app's own settings list has no such row
|
||||
* either) -- the doc's own claim that "服务端只保存应用内通知偏好" has nothing backing it. Rather
|
||||
* than build toggles that write nowhere, this only wires the one thing that's real and entirely
|
||||
* client-side: the browser's own Notification permission.
|
||||
*/
|
||||
export function NotificationsSettingsPage() {
|
||||
const [permission, setPermission] = useState<NotificationPermission>(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
|
||||
|
||||
const request = async () => {
|
||||
if (typeof Notification === 'undefined') return;
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
};
|
||||
|
||||
const label = { granted: '已允许', denied: '已拒绝', default: '未设置' }[permission];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-4 text-xl font-semibold text-text-heading">消息通知</h2>
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-text-body">桌面通知</span>
|
||||
<span className="text-xs text-text-tertiary-3">由浏览器管理,用于在新消息到达时提醒你</span>
|
||||
</div>
|
||||
{permission === 'default' ? (
|
||||
<button type="button" onClick={request} className="h-8 flex-none rounded-lg bg-btn px-3 text-xs font-medium text-white">
|
||||
开启
|
||||
</button>
|
||||
) : (
|
||||
<span className={`flex-none text-xs ${permission === 'granted' ? 'text-success' : 'text-text-tertiary-2'}`}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getBlocks } from '@/lib/api/friends';
|
||||
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
|
||||
|
||||
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
|
||||
{ key: 'account', label: '通过用户 ID' },
|
||||
{ key: 'phone', label: '通过手机号' },
|
||||
{ key: 'qr', label: '通过二维码' },
|
||||
{ key: 'group', label: '通过群聊' },
|
||||
];
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'everyone', label: '所有人' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
{ value: 'nobody', label: '不展示' },
|
||||
];
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={onChange}
|
||||
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
|
||||
>
|
||||
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
|
||||
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
|
||||
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
|
||||
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
|
||||
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
|
||||
* found during research) -- given their own third card rather than folding them into either of the
|
||||
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
|
||||
*/
|
||||
export function PrivacySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState('');
|
||||
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
|
||||
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
|
||||
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
|
||||
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
|
||||
// from the cache after every single save, most importantly `editable`: once it silently
|
||||
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
|
||||
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
|
||||
// to restore it.
|
||||
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
|
||||
// Same reasoning as ProfileSettingsPage's own saveMutation: a version conflict shouldn't leave
|
||||
// the cached `version` stale, or every retry just resends the same rejected value forever.
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '设置失败');
|
||||
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
|
||||
},
|
||||
});
|
||||
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
|
||||
if (!query.data) return;
|
||||
mutation.mutate({ ...partial, version: query.data.version });
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
|
||||
// error, but `query.data` stays undefined either way -- the page was falling through to the same
|
||||
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
|
||||
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
|
||||
if (query.isError || !query.data) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const editable = query.data.editable || {};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
|
||||
{ADD_ME_LABELS.map((item, i) => (
|
||||
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
|
||||
<span className="text-text-body">{item.label}</span>
|
||||
<Toggle
|
||||
checked={query.data.add_me_methods[item.key]}
|
||||
disabled={editable.add_me_methods === false || mutation.isPending}
|
||||
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
|
||||
<span className="text-text-body">黑名单</span>
|
||||
<span className="flex items-center gap-1 text-text-tertiary-2">
|
||||
{blocksQuery.data?.length ?? 0} 人
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
|
||||
<span className="text-text-body">资料可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.profile_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ profile_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
|
||||
<span className="text-text-body">最近在线可见范围</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
|
||||
{VISIBILITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={editable.last_seen_visibility === false || mutation.isPending}
|
||||
onClick={() => patch({ last_seen_visibility: opt.value })}
|
||||
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-7 py-4">
|
||||
<span className="text-text-body">已读回执</span>
|
||||
<Toggle
|
||||
checked={query.data.read_receipts}
|
||||
disabled={editable.read_receipts === false || mutation.isPending}
|
||||
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
|
||||
import { uploadFile } from '@/lib/api/upload';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [
|
||||
{ value: 'male', label: '男' },
|
||||
{ value: 'female', label: '女' },
|
||||
{ value: 'other', label: '保密' },
|
||||
];
|
||||
const DEFAULT_MAX_SIGNATURE_LENGTH = 60;
|
||||
|
||||
/** Plain-text-looking field: a real `<input>` with all box chrome stripped so it reads as the
|
||||
* design's "label / value / edit-affordance-icon" row instead of a boxed form control, while
|
||||
* staying genuinely editable (click anywhere in the value cell to type). */
|
||||
const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
|
||||
* `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT
|
||||
* `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise
|
||||
* registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type
|
||||
* is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated
|
||||
* user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293
|
||||
* -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too,
|
||||
* docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now
|
||||
* returns 200).
|
||||
*/
|
||||
export function ProfileSettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile });
|
||||
const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [showQrCode, setShowQrCode] = useState(false);
|
||||
const birthDateRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data && !draft) {
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
});
|
||||
}
|
||||
}, [query.data, draft]);
|
||||
|
||||
// saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled=
|
||||
// props below on the avatar input/save button stop the two from ever being pending at the same
|
||||
// time, closing the race where both could send the SAME stale `query.data.version` and whichever
|
||||
// response resolves last would win the cache. Independent of that: EITHER mutation's own response
|
||||
// can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line
|
||||
// 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting
|
||||
// `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own
|
||||
// request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the
|
||||
// most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would
|
||||
// erase those fields from the cache after a single avatar change -- merging onto the existing
|
||||
// cached object (not just overwriting it) is what actually prevents that regardless of exactly how
|
||||
// partial a given response turns out to be.
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!query.data || !draft) throw new Error('尚未加载');
|
||||
return updateMyProfile({ ...draft, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
},
|
||||
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
|
||||
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
|
||||
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the
|
||||
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. Worse: even
|
||||
// once `query.data` DOES refetch fresh, `draft` (what's actually on screen and what a follow-up
|
||||
// save would resubmit) was never resynced -- a second "保存" click would then silently succeed
|
||||
// and overwrite whichever OTHER fields changed elsewhere with this tab's own stale values.
|
||||
onError: (err) => {
|
||||
if (err instanceof ImApiError && err.reason === 'VERSION_CONFLICT') {
|
||||
setError('资料已在其他设备更新,你的修改未保存,请重新编辑');
|
||||
// Resets to null (not the fresh data directly, which isn't available here yet) so the
|
||||
// effect above repopulates `draft` once the invalidated query below refetches -- same path
|
||||
// as the initial load, guaranteeing `draft` never observably lags `query.data`'s version.
|
||||
setDraft(null);
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
|
||||
},
|
||||
});
|
||||
|
||||
const avatarMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const uploaded = await uploadFile(file, undefined, 'user_avatar');
|
||||
if (!query.data) throw new Error('尚未加载');
|
||||
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err instanceof Error ? err.message : '头像上传失败');
|
||||
setUploadingAvatar(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('图片大小不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
avatarMutation.mutate(file);
|
||||
};
|
||||
|
||||
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
|
||||
// Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch
|
||||
// (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false
|
||||
// but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no
|
||||
// way to retry.
|
||||
if (query.isError || !draft) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH;
|
||||
const dirty =
|
||||
query.data &&
|
||||
(draft.nickname !== query.data.nickname ||
|
||||
draft.gender !== (query.data.gender || '') ||
|
||||
draft.birth_date !== (query.data.birth_date || '') ||
|
||||
draft.region !== (query.data.region || '') ||
|
||||
draft.signature !== (query.data.signature || ''));
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">个人资料</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
|
||||
<div className="relative flex-none">
|
||||
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
|
||||
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
|
||||
<Icon name="photo_camera" className="text-[15px]" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handlePickAvatar}
|
||||
className="hidden"
|
||||
disabled={uploadingAvatar || saveMutation.isPending}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
|
||||
<span className="truncate text-[13px] text-text-tertiary-2">
|
||||
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQrCode(true)}
|
||||
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
|
||||
>
|
||||
<Icon name="qr_code_2" className="text-[18px]" />
|
||||
<span className="hidden sm:inline">我的二维码</span>
|
||||
</button>
|
||||
</div>
|
||||
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
|
||||
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">昵称</span>
|
||||
<InlineField
|
||||
value={draft.nickname}
|
||||
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
|
||||
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
|
||||
/>
|
||||
<Icon name="edit" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">性别</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setDraft({ ...draft, gender: opt.value })}
|
||||
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">出生日期</span>
|
||||
<InlineField
|
||||
ref={birthDateRef}
|
||||
type="date"
|
||||
value={draft.birth_date}
|
||||
max={new Date().toISOString().slice(0, 10)}
|
||||
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
|
||||
/>
|
||||
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
|
||||
<Icon name="calendar_today" className="text-[20px]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="text-text-secondary">地区</span>
|
||||
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
|
||||
<span className="pt-2 text-text-secondary">个性签名</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={draft.signature}
|
||||
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
|
||||
rows={3}
|
||||
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
|
||||
/>
|
||||
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
|
||||
{draft.signature.length}/{maxSignatureLength}
|
||||
</span>
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
query.data &&
|
||||
setDraft({
|
||||
nickname: query.data.nickname,
|
||||
gender: query.data.gender || '',
|
||||
birth_date: query.data.birth_date || '',
|
||||
region: query.data.region || '',
|
||||
signature: query.data.signature || '',
|
||||
})
|
||||
}
|
||||
disabled={!dirty}
|
||||
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
|
||||
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{saveMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getTinode, resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* screens/07-profile-settings.dc.html#1b. That mockup's 手机号/登录设备 rows have no real data
|
||||
* source and aren't built as decorative fakes: phone number display (no `GET my phone` endpoint
|
||||
* anywhere -- the only place a phone surfaces is a masked hint as a SIDE EFFECT of starting an
|
||||
* unrelated SMS challenge, not a passive read) and the logged-in-device list (only a kick-by-id
|
||||
* DELETE exists, itself marked 待实现, with no GET list endpoint to populate rows from). Password
|
||||
* change and account deletion both go through native Tinode `{acc}`/`{del}` calls (see
|
||||
* tinode-sdk.d.ts's own comments) -- there is no REST path for either, confirmed across both doc
|
||||
* copies and the backend problem list.
|
||||
*/
|
||||
export function SecuritySettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const [changingPassword, setChangingPassword] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [deletingAccount, setDeletingAccount] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
// Real per-tenant policy (docs/IM-前后端对接文档.md:534, fetched once at tenant-resolve time and
|
||||
// stored on `tenant`) -- was hardcoded to 6 here, silently DIFFERENT from Register/ResetPassword's
|
||||
// own (also-hardcoded, but at least matching each other) 8, so a password this page accepted could
|
||||
// still get rejected by the backend's real policy.
|
||||
const minPasswordLength = tenant?.passwordPolicy?.minLength ?? 8;
|
||||
|
||||
const passwordsMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
|
||||
|
||||
const passwordMutation = useMutation({
|
||||
// `updateAccountBasic`'s own type comment (tinode-sdk.d.ts) already confirms, from this
|
||||
// engagement's earlier research into the old app's real working code: the server does NOT
|
||||
// verify a "current password" for this call at all -- an already-authenticated WS session is
|
||||
// by itself sufficient to change the password to anything. `currentPassword` below is therefore
|
||||
// NOT sent anywhere and is NOT a real credential check -- it exists purely as a client-side "did
|
||||
// you mean to do this" gate (the submit button stays disabled while it's empty), the same
|
||||
// fat-finger/shared-device speed bump most password-change screens have, not a security boundary.
|
||||
// A REAL re-verification would need the protocol's own `POST /security/challenges` sensitive-
|
||||
// action flow, but the only confirmed `scene` values for that endpoint (`set_independent_password`/
|
||||
// `close_youth_mode`) are both youth-mode-specific -- and youth mode was removed from this app
|
||||
// entirely per an earlier explicit user request, so that path isn't available here either.
|
||||
mutationFn: () => {
|
||||
const tinode = getTinode();
|
||||
return tinode.updateAccountBasic(null, tinode.getCurrentLogin(), newPassword);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setSuccess('密码已修改。');
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '密码修改失败'),
|
||||
});
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (deletingAccount) return;
|
||||
if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return;
|
||||
if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return;
|
||||
setDeletingAccount(true);
|
||||
try {
|
||||
await getTinode().delCurrentUser(true);
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- see SettingsLayout.tsx's logout for why: it clears
|
||||
// useSessionStore's `tenant`, and LoginPage's own tenant guard immediately bounces navigate(
|
||||
// '/login') below back to the tenant-code entry screen instead of this tenant's login page.
|
||||
//
|
||||
// Same shared-device concern as SettingsLayout's logout -- a next login in this tab should
|
||||
// never briefly render this now-deleted account's cached data.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注销失败');
|
||||
setDeletingAccount(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
|
||||
<h2 className="mb-5 text-xl font-semibold text-text-heading">账号与安全</h2>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="rounded-xl bg-white text-sm">
|
||||
{changingPassword ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPassword.length === 0 || newPassword.length < minPasswordLength || passwordsMismatch || confirmPassword.length === 0 || passwordMutation.isPending) return;
|
||||
passwordMutation.mutate();
|
||||
}}
|
||||
className="flex flex-col gap-3 px-7 py-4"
|
||||
>
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
autoComplete="current-password"
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={`输入新密码(至少 ${minPasswordLength} 位)`}
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className="rounded-md bg-surface-input px-3 py-2 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
autoComplete="new-password"
|
||||
maxLength={tenant?.passwordPolicy?.maxLength}
|
||||
className={`rounded-md bg-surface-input px-3 py-2 outline-none ${passwordsMismatch ? 'border border-danger-text' : ''}`}
|
||||
/>
|
||||
{passwordsMismatch ? <span className="text-xs text-danger-border">两次输入的密码不一致</span> : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChangingPassword(false);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
}}
|
||||
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
currentPassword.length === 0 ||
|
||||
newPassword.length < minPasswordLength ||
|
||||
passwordsMismatch ||
|
||||
confirmPassword.length === 0 ||
|
||||
passwordMutation.isPending
|
||||
}
|
||||
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
|
||||
>
|
||||
{passwordMutation.isPending ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
|
||||
<span className="text-text-body">修改密码</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={deleteAccount}
|
||||
disabled={deletingAccount}
|
||||
className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm disabled:opacity-50"
|
||||
>
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="text-text-body">注销账号</span>
|
||||
<span className="text-xs text-text-tertiary-2">{deletingAccount ? '注销中…' : '注销后数据不可恢复'}</span>
|
||||
</span>
|
||||
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
<Toast message={success} variant="success" onDone={() => setSuccess('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
|
||||
import { useMediaQuery } from '@/lib/hooks';
|
||||
|
||||
/**
|
||||
* `/settings` alone has no natural "empty" content the way contacts/messages do -- on desktop
|
||||
* (directory + content docked side by side) the most-relevant first section fills the content pane
|
||||
* directly. On narrow viewports this must NOT redirect there (an earlier version did): SettingsLayout
|
||||
* only shows the directory list at this exact bare path, and a redirect away from it would bounce
|
||||
* straight past that list before a phone-width visitor ever saw it -- so this renders nothing there,
|
||||
* leaving the directory (rendered by the parent layout) as the whole screen.
|
||||
*/
|
||||
export function SettingsHome() {
|
||||
const isDesktop = useMediaQuery('(min-width: 960px)');
|
||||
return isDesktop ? <ProfileSettingsPage /> : null;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Avatar } from '@/app/Avatar';
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { logout as revokeServerSession } from '@/lib/api/auth';
|
||||
import { getMe } from '@/lib/api/profile';
|
||||
import { resetTinode } from '@/lib/tinode/connection';
|
||||
import { requestConfirm } from '@/stores/confirmDialog';
|
||||
import { notImplemented } from '@/stores/globalToast';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DIRECTORY = [
|
||||
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
|
||||
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
|
||||
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
|
||||
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
|
||||
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
|
||||
];
|
||||
|
||||
/**
|
||||
* README §8.7 (mobile "我的" page): 我的收藏/通话记录 are grouped ABOVE the regular settings
|
||||
* directory. Desktop never needs this list -- those already have their own permanent NavRail icons
|
||||
* there -- but on a phone-width viewport (bottom tab bar instead of a rail) there is otherwise NO
|
||||
* way to reach `/favorites` or `/calls` at all short of typing the URL directly (confirmed live:
|
||||
* BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2 when those two routes didn't
|
||||
* exist yet) -- a real navigation gap this Phase 9 pass caught, not a cosmetic one. 文件助手 used to
|
||||
* be a third entry here (reusing the same `slf`-conversation redirect the desktop NavRail icon did)
|
||||
* but was removed along with every other entry point -- see ChatListColumn.tsx's PINNED_ENTRIES
|
||||
* doc comment for why.
|
||||
*/
|
||||
const MOBILE_SHORTCUTS = [
|
||||
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
|
||||
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
|
||||
];
|
||||
|
||||
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
|
||||
export function SettingsLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
|
||||
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
|
||||
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
|
||||
const [mobileSubView, setMobileSubView] = useState(false);
|
||||
|
||||
const logout = async () => {
|
||||
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
|
||||
setLoggingOut(true);
|
||||
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
|
||||
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
|
||||
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
|
||||
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
|
||||
await revokeServerSession().catch(() => {});
|
||||
resetTinode();
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
setMyUserId(null);
|
||||
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
|
||||
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
|
||||
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
|
||||
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
|
||||
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
|
||||
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
|
||||
//
|
||||
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
|
||||
// client-side route change -- without this, a second account logging in on the same tab/device
|
||||
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
|
||||
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
|
||||
queryClient.clear();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
|
||||
// this, the 260px directory and the content column both try to render side by side on a phone-
|
||||
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
|
||||
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
|
||||
// directory only at the bare `/settings` index, content only once a specific section is picked.
|
||||
const showListOnNarrow = location.pathname === '/settings';
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
|
||||
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
|
||||
<div className="hidden h-full flex-1 flex-col md:flex">
|
||||
<header className="flex h-15 flex-none items-center px-5">
|
||||
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
|
||||
</header>
|
||||
<div className="flex items-center gap-3 border-b border-border-row px-5 pb-4.5 pt-2">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-12 w-12 flex-none rounded-xl text-base"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
|
||||
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
|
||||
{DIRECTORY.map((item) => {
|
||||
const active = location.pathname.startsWith(item.to);
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
|
||||
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
|
||||
}`}
|
||||
>
|
||||
<Icon name={item.icon} className="text-[20px]" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={loggingOut}
|
||||
className="mx-5 mt-auto mb-5 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
|
||||
tappable profile card (-> /settings/profile), 我的收藏/通话记录 (this app's only way to
|
||||
reach those two on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
|
||||
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
|
||||
link -- these two screens genuinely don't share one flat structure in the mock. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
|
||||
<Avatar
|
||||
id={meQuery.data?.user_id || ''}
|
||||
name={meQuery.data?.profile.nickname}
|
||||
avatarFileId={meQuery.data?.profile.avatar_file_id}
|
||||
className="h-16 w-16 flex-none rounded-2xl text-2xl"
|
||||
/>
|
||||
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
|
||||
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
|
||||
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{MOBILE_SHORTCUTS.map((item, i) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="security" className="text-xl text-[#7c5cf6]" />
|
||||
<span className="flex-1">账号与安全</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<Icon name="lock" className="text-xl text-[#e2537a]" />
|
||||
<span className="flex-1">隐私</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
|
||||
<Icon name="settings" className="text-xl text-text-tertiary-3" />
|
||||
<span className="flex-1">设置</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
|
||||
above and the desktop directory: 清空缓存/意见反馈 sit here directly, not nested under
|
||||
通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
|
||||
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
|
||||
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
|
||||
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
|
||||
is a real, recorded backend gap, not a missed wire-up. 关于 MeetingIM removed entirely per
|
||||
product decision -- its only real content was a version string and an update-check the
|
||||
backend rejects for `platform=web`, not worth a dedicated page. */}
|
||||
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
|
||||
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
|
||||
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
|
||||
<Icon name="arrow_back_ios_new" className="text-[20px]" />
|
||||
</button>
|
||||
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
|
||||
<span />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 py-2.5">
|
||||
<div className="flex flex-col bg-white">
|
||||
{DIRECTORY.filter((item) => item.key !== 'profile').map((item, i, arr) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.to}
|
||||
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col bg-white">
|
||||
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
|
||||
<span>清空缓存</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</Link>
|
||||
<button type="button" onClick={notImplemented} className="flex items-center justify-between px-5 py-3.5 text-left text-base text-text-body">
|
||||
<span>意见反馈</span>
|
||||
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
|
||||
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
|
||||
<Icon name="arrow_back" className="text-[18px]" />
|
||||
设置
|
||||
</Link>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { acceptGroupInvitation } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
|
||||
/**
|
||||
* Ported verbatim from the old app's message-center-view.jsx (lines ~420-455) -- the backend writes
|
||||
* raw event codes into `summary` in several real cases instead of human text (confirmed live,
|
||||
* docs/IM-后端问题清单.md 问题30), so this maps event/summary codes to display text rather than
|
||||
* trusting `summary` blindly. Lookup order: NOTIFICATION_TEXT[summary] first (covers the bare
|
||||
* "accepted"/"rejected"/"continue"/"cancel" codes), then NOTIFICATION_TEXT[event_type], then the
|
||||
* raw summary (covers genuinely human text like a meeting title or report date), then a generic
|
||||
* fallback -- never silently show nothing.
|
||||
*/
|
||||
const NOTIFICATION_TEXT: Record<string, string> = {
|
||||
'group.created': '你创建了一个新群聊',
|
||||
'group.join_request.created': '有人申请加入你管理的群聊',
|
||||
'group.announcement.updated': '群公告有更新',
|
||||
'group.member.joined': '有成员加入了群聊',
|
||||
'group.member.left': '有成员退出了群聊',
|
||||
'group.member.updated': '群成员资料有更新',
|
||||
'group.mute.updated': '群禁言设置有变更',
|
||||
'group.owner.transferred': '群主已转让',
|
||||
'group.role.updated': '群成员角色有变更',
|
||||
'group.settings.updated': '群设置有更新',
|
||||
'group.join_request.accepted': '你的入群申请已通过',
|
||||
'group.join_request.rejected': '你的入群申请被拒绝',
|
||||
accepted: '对方已通过你的好友申请',
|
||||
rejected: '对方拒绝了你的好友申请',
|
||||
continue: '你确认继续了一次安全风险提示',
|
||||
cancel: '你取消了一次风险操作确认',
|
||||
};
|
||||
|
||||
function notificationText(item: SystemNotification): string {
|
||||
return NOTIFICATION_TEXT[item.summary] || NOTIFICATION_TEXT[item.event_type] || item.summary || '您有一条新的系统通知。';
|
||||
}
|
||||
|
||||
/** icon/color per event family -- matches the design's 新设备登录/好友申请/群聊动态/版本更新
|
||||
* categories, generalized by event_type prefix since the real event set is broader than those 4. */
|
||||
function iconForEvent(eventType: string): { icon: string; className: string } {
|
||||
if (eventType.startsWith('friend.')) return { icon: 'person_add', className: 'bg-accent-soft-bg text-accent' };
|
||||
if (eventType.startsWith('group.')) return { icon: 'groups', className: 'bg-success-bg text-success' };
|
||||
if (eventType.startsWith('security.')) return { icon: 'gpp_maybe', className: 'bg-error-strip-bg text-unread' };
|
||||
if (eventType.startsWith('meeting.')) return { icon: 'event', className: 'bg-warn-bg text-warn' };
|
||||
return { icon: 'notifications', className: 'bg-warn-bg text-warn' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes on `event_type`, NOT `action.type` -- confirmed live (docs/IM-后端问题清单.md 问题30) a
|
||||
* real friend-request notification's `action` is just `{"user_id":"..."}`, no `type` key at all,
|
||||
* contradicting this doc's own worked example. `action.device`/`action.ip` (new-device-login) are
|
||||
* typed but confirmed NOT populated by any real response yet (新增12, still open) -- this simply
|
||||
* won't render a link for that case until the backend actually implements it, rather than building
|
||||
* a rich device/IP card around data that can't exist yet.
|
||||
*/
|
||||
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
|
||||
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
|
||||
// Handled separately (see the card's own `action.type === 'group_invitation_accept'` branch below,
|
||||
// rendering a real "接受邀请" button) -- NOT a plain navigation like every other group.* event.
|
||||
// You are not yet a member of this group at the point this notification arrives, so navigating
|
||||
// straight to `/messages/{topic}` (what the branch below would otherwise do, since this event also
|
||||
// carries a real `grp...` action.topic) would open a conversation you have no access to.
|
||||
if (item.action?.type === 'group_invitation_accept') return null;
|
||||
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
|
||||
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
|
||||
}
|
||||
if (item.action?.device || item.action?.ip) return { label: '前往安全设置', to: '/settings/security' };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** README §9.1 (four-column layout, no detail rail). No "mark all read" endpoint exists anywhere
|
||||
* in the protocol doc (confirmed via a research pass) -- "全部标为已读" loops the single-item
|
||||
* `POST .../read` over every currently-unread item instead of a batch call that doesn't exist. */
|
||||
export function SystemMessagesView() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const bootstrap = useBootstrap();
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['system-notifications'],
|
||||
queryFn: ({ pageParam }) => getSystemNotifications(pageParam),
|
||||
initialPageParam: '',
|
||||
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
|
||||
});
|
||||
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const unread = items.filter((i) => !i.read_at);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const readMutation = useMutation({
|
||||
mutationFn: (id: string) => markSystemNotificationRead(id),
|
||||
onSuccess: (data, id) => {
|
||||
queryClient.setQueryData(['system-notifications'], (prev: typeof query.data) =>
|
||||
prev
|
||||
? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: p.items.map((i) => (i.id === id ? { ...i, read_at: data.read_at } : i)) })) }
|
||||
: prev,
|
||||
);
|
||||
// AppShell's own NavRail/BottomTabBar unread badge trusts `bootstrap.entry_badges.
|
||||
// system_unread_count` as its ONLY source for this topic (deliberately excluded from the
|
||||
// live per-conversation sum) -- but that query is `staleTime: Infinity` and nothing anywhere
|
||||
// else invalidates it, so without this the badge would never decrease within a session no
|
||||
// matter how many notifications get marked read here.
|
||||
queryClient.invalidateQueries({ queryKey: ['bootstrap'] });
|
||||
},
|
||||
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
|
||||
});
|
||||
|
||||
// README/docs :1464/1739's `group.invitation.created` flow -- the doc marks the accept endpoint
|
||||
// "待实现" but it's real (see acceptGroupInvitation's own comment). Success takes you straight to
|
||||
// the group (you're a member now); failure is surfaced inline on the card itself (an expired/
|
||||
// already-handled invitation, or a race where someone else's action beat you to it), not just a
|
||||
// toast, so it doesn't look like the button silently did nothing.
|
||||
const [invitationError, setInvitationError] = useState<Record<string, string>>({});
|
||||
// Real, reported bug: this card's "接受邀请" button stayed live and clickable forever, even for an
|
||||
// invitation already accepted (in an earlier visit to this same list) or whose group had since been
|
||||
// dissolved -- there was no concept of "this one's done" anywhere, on success OR failure, so every
|
||||
// render just re-derived "show the button" from the notification's own static `action` payload,
|
||||
// which the server never updates after the fact. Once EITHER outcome happens for a given
|
||||
// invitation_id, it should never look freshly-clickable again -- a genuinely transient failure
|
||||
// (a network blip) is rare enough, and "already handled"/"group gone" common enough, that treating
|
||||
// every failure as terminal is the safer default. Doesn't survive a page reload (the server's own
|
||||
// notification record has no resolved flag to read back), only this list staying mounted -- a real,
|
||||
// acknowledged gap, not silently pretended away.
|
||||
const [resolvedInvitations, setResolvedInvitations] = useState<Record<string, 'accepted' | 'failed'>>({});
|
||||
const acceptInvitationMutation = useMutation({
|
||||
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
|
||||
onSuccess: (data, invitationId) => {
|
||||
setInvitationError((prev) => {
|
||||
if (!prev[invitationId]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[invitationId];
|
||||
return next;
|
||||
});
|
||||
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
|
||||
navigate(`/messages/${data.topic}`);
|
||||
},
|
||||
onError: (err, invitationId) => {
|
||||
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
|
||||
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' }));
|
||||
},
|
||||
});
|
||||
|
||||
const [markingAll, setMarkingAll] = useState(false);
|
||||
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
|
||||
// used to leave any unread notification on a page the user never scrolled to untouched, while the
|
||||
// button itself still disappeared (since it's gated on the loaded-only `unread.length`), falsely
|
||||
// implying everything had been cleared. Pages through every remaining page first, then marks the
|
||||
// now-complete set, so "全部" is actually all of it.
|
||||
const markAllRead = async () => {
|
||||
setMarkingAll(true);
|
||||
try {
|
||||
// `query` is a snapshot from THIS render -- fetchNextPage() updates the cache and causes a
|
||||
// re-render with a NEW query object, but this closure keeps holding the old one, so its
|
||||
// `.hasNextPage` never changes and this loop would never exit for any account with more than
|
||||
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
|
||||
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
|
||||
// of trusting a captured object's field is what actually terminates.
|
||||
// `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver
|
||||
// catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor`
|
||||
// exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on
|
||||
// any transient failure (expired token, network blip), leaving `markingAll` stuck true and
|
||||
// this button permanently showing "标记中…" with nothing telling the user why. Capped so a
|
||||
// failure surfaces as an error instead of hanging.
|
||||
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
let guard = 0;
|
||||
while (cursor && guard++ < 200) {
|
||||
await query.fetchNextPage();
|
||||
const next = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
|
||||
if (next === cursor) {
|
||||
setError('翻页失败,请重试');
|
||||
return;
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
|
||||
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
|
||||
// it the same as mutate) -- this swallow only stops Promise.all's fail-fast rejection from
|
||||
// ALSO becoming an unhandled rejection on top of that, it doesn't hide the error itself.
|
||||
await Promise.all(allItems.filter((i) => !i.read_at).map((i) => readMutation.mutateAsync(i.id))).catch(() => {});
|
||||
} finally {
|
||||
setMarkingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-y-auto bg-[#eef1f4]">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
|
||||
<Icon name="arrow_back_ios_new" />
|
||||
</Link>
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-warn-bg text-warn">
|
||||
<Icon name="notifications" className="text-xl" />
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
|
||||
<span className="text-xs text-text-tertiary-3">由 迅工捷使 安全团队发送,不可回复</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself
|
||||
correctly walks every remaining page (see its own comment below) -- gating visibility on
|
||||
`unread.length` alone defeats that fix by hiding the button the moment the FIRST page
|
||||
happens to show zero unread, even while an older unread notification sits unreached on a
|
||||
later page. `bootstrap.entry_badges.system_unread_count` is the same authoritative,
|
||||
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
|
||||
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
|
||||
whenever it's available. */}
|
||||
{/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is
|
||||
`staleTime: Infinity` and only ever invalidated in the "count goes down" direction
|
||||
(readMutation's onSuccess below), so a cached `0` from before any new notification
|
||||
arrived would otherwise out-vote a real non-empty `unread` this render actually has. */}
|
||||
{Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? (
|
||||
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
|
||||
{markingAll ? '标记中…' : '全部标为已读'}
|
||||
</button>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="flex flex-col items-center gap-3 p-6">
|
||||
{query.isLoading ? (
|
||||
<div className="py-10 text-sm text-text-tertiary-4">加载中…</div>
|
||||
) : query.isError ? (
|
||||
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
|
||||
<span>加载失败,请重试</span>
|
||||
<button type="button" onClick={() => query.refetch()} className="text-accent">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-sm text-text-tertiary-4">暂无系统消息</div>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const { icon, className } = iconForEvent(item.event_type);
|
||||
const action = actionRoute(item);
|
||||
const isUnread = !item.read_at;
|
||||
// The whole card is the click target, not just the "查看…" link below -- most
|
||||
// notifications (好友申请已通过/拒绝, most 群聊动态) have no `actionRoute` at all, so
|
||||
// gating mark-read on that inner link left them with literally nothing clickable
|
||||
// anywhere on the card, permanently stuck showing "新" no matter how long they sat there
|
||||
// (a real, reported bug: "有些消息没有链接,没地方点击就一直是新"). A card with an action
|
||||
// still marks read AND navigates; one without just marks read, since there's nowhere to go.
|
||||
const activate = () => {
|
||||
if (isUnread) readMutation.mutate(item.id);
|
||||
if (action) navigate(action.to);
|
||||
};
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={activate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
className={`w-full flex-none cursor-pointer rounded-xl bg-white p-[18px] text-left md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}
|
||||
>
|
||||
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.created_at))}</div>
|
||||
<div className="mt-2 flex items-start gap-3">
|
||||
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg ${className}`}>
|
||||
<Icon name={icon} className="text-xl" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<strong className="flex-1 text-[15px] font-semibold text-text-heading">{item.title}</strong>
|
||||
{isUnread ? <b className="flex-none rounded bg-unread px-1 text-[10px] leading-[14px] text-white">新</b> : null}
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[#4a4f57]">{notificationText(item)}</p>
|
||||
{/* Plain text, not a nested `<button>` -- it's inside the card's own click target
|
||||
above, and a button-in-button (role="button" div counts the same way) is the
|
||||
same illegal-nesting/double-fire shape already fixed once in this codebase for
|
||||
favorites' voice player. */}
|
||||
{action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
|
||||
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
|
||||
<div className="mt-1 flex flex-col items-start gap-1.5">
|
||||
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
|
||||
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
|
||||
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
|
||||
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
|
||||
) : (
|
||||
// A REAL nested button, unlike the plain-text action label above -- accepting
|
||||
// is a distinct action from "open this card" (which only marks it read; see
|
||||
// actionRoute's own comment for why this event type has no plain navigation
|
||||
// at all), so stopPropagation is the right tool here rather than avoiding the
|
||||
// nesting the way the plain-text case does.
|
||||
<button
|
||||
type="button"
|
||||
disabled={acceptInvitationMutation.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isUnread) readMutation.mutate(item.id);
|
||||
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
|
||||
}}
|
||||
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
|
||||
? '处理中…'
|
||||
: '接受邀请'}
|
||||
</button>
|
||||
)}
|
||||
{invitationError[item.action.invitation_id] ? (
|
||||
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
|
||||
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
|
||||
{item.event_type.startsWith('security.') ? (
|
||||
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · 迅工捷使 安全团队</footer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{query.hasNextPage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => query.fetchNextPage()}
|
||||
disabled={query.isFetchingNextPage}
|
||||
className="py-2 text-sm text-accent disabled:opacity-60"
|
||||
>
|
||||
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Material Icons' OWN Google Fonts stylesheet (index.html's `<link>`) ships its `.material-icons`
|
||||
* class completely unlayered -- plain CSS, no `@layer`. Tailwind v4 (the `@import` above) puts every
|
||||
* one of its own utilities inside `@layer utilities`. Per the CSS Cascade Layers spec, ANY unlayered
|
||||
* declaration beats EVERY layered one for the same property on the same element, regardless of
|
||||
* selector specificity or which stylesheet loads later in the document -- confirmed live: an
|
||||
* `<Icon className="text-[14px]">` computed to `font-size: 24px` regardless of the requested size,
|
||||
* every time, because Google's unlayered rule was always winning over Tailwind's layered
|
||||
* `.text-\[14px\]` utility. Icon.tsx uses its own `im-icon` class (not `.material-icons`) specifically
|
||||
* so Google's competing rule never matches these elements at all -- this is the same visual default
|
||||
* (24px, line-height 1, etc., copied 1:1 from Google's own stylesheet), just declared inside `@layer
|
||||
* base` so Tailwind's `@layer utilities` (which Tailwind itself registers after `base`) can actually
|
||||
* override it, the way every call site sizing an icon via `text-[Npx]` already assumed it could.
|
||||
*/
|
||||
@layer base {
|
||||
.im-icon {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
|
||||
* section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
|
||||
* sync with that doc, not with whatever the previous im_webapp's css/base.css happened to use.
|
||||
*
|
||||
* Breakpoints are intentionally remapped onto Tailwind's own sm/md/lg so every component can just
|
||||
* use the standard `sm:`/`md:`/`lg:` prefixes (min-width, mobile-first) instead of inventing custom
|
||||
* variants: <640 base styles = single column mobile, sm: (>=640) = two column, md: (>=960) = three
|
||||
* column, lg: (>=1280) = four column docked detail panel.
|
||||
*/
|
||||
@theme {
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 960px;
|
||||
--breakpoint-lg: 1280px;
|
||||
|
||||
--font-sans: Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
/* Primary / accent */
|
||||
--color-accent: #1677ff;
|
||||
--color-accent-soft-bg: #e3eefe;
|
||||
--color-accent-selected-bg: #e8f1fe;
|
||||
--color-focus-border: #4091ff;
|
||||
|
||||
--color-btn: #368bfa;
|
||||
--color-btn-hover: #1677e8;
|
||||
--color-btn-disabled: #b8d5fa;
|
||||
|
||||
/* Sent bubble */
|
||||
--color-bubble-mine: #b9eb8f;
|
||||
|
||||
/* Surfaces */
|
||||
--color-surface-chat: #eef1f4;
|
||||
--color-surface-content: #f6f7f9;
|
||||
--color-surface-page: #f7f9fc;
|
||||
--color-surface-rail: #f2f4f7;
|
||||
--color-surface-input: #f2f4f7;
|
||||
--color-surface-search: #f1f2f4;
|
||||
|
||||
/* Borders */
|
||||
--color-border-panel: #eceef1;
|
||||
--color-border-row: #f1f2f4;
|
||||
--color-border-list-item: #f4f5f7;
|
||||
|
||||
/* Text */
|
||||
--color-text-title: #14171c;
|
||||
--color-text-heading: #22262d;
|
||||
--color-text-body: #252931;
|
||||
--color-text-secondary: #656b74;
|
||||
--color-text-tertiary-1: #4a4f57;
|
||||
--color-text-tertiary-2: #969ca5;
|
||||
--color-text-tertiary-3: #9ca2aa;
|
||||
--color-text-tertiary-4: #8d949d;
|
||||
--color-text-placeholder-1: #b2b7bf;
|
||||
--color-text-placeholder-2: #a5abb4;
|
||||
|
||||
--color-icon-default: #555c65;
|
||||
--color-icon-arrow: #c2c7ce;
|
||||
|
||||
/* Status */
|
||||
--color-unread: #f34b46;
|
||||
--color-danger-text: #d94b4b;
|
||||
--color-danger-border: #cf3f3f;
|
||||
--color-danger-border-soft: #f3c4c4;
|
||||
--color-online: #1fb36a;
|
||||
--color-offline: #aeb4bd;
|
||||
|
||||
/* Orange (system messages / group owner / warning) */
|
||||
--color-warn: #ff951c;
|
||||
--color-warn-bg: #fff5e5;
|
||||
--color-announce: #f5a623;
|
||||
--color-announce-bg: #fff8e6;
|
||||
--color-announce-text: #6d4c41;
|
||||
|
||||
/* Green (file assistant / success) */
|
||||
--color-success: #16ae54;
|
||||
--color-success-bg: #eefaf3;
|
||||
|
||||
/* Alert strips */
|
||||
--color-error-strip-bg: #ffeef0;
|
||||
--color-error-strip-text: #b51c1c;
|
||||
--color-info-strip-bg: #e8f1fe;
|
||||
--color-info-strip-text: #1a4f9c;
|
||||
|
||||
/* Call surfaces (dark) */
|
||||
--color-call-bg-1: #1b2231;
|
||||
--color-call-bg-2: #1f2a3d;
|
||||
--color-call-bg-3: #3b4a63;
|
||||
|
||||
/* Avatar palette (deterministic per-contact hashing picks from this list) */
|
||||
--color-avatar-1: #3c8df6;
|
||||
--color-avatar-2: #7c5cf6;
|
||||
--color-avatar-3: #2bb673;
|
||||
--color-avatar-4: #f28c3b;
|
||||
--color-avatar-5: #e2537a;
|
||||
--color-avatar-6: #0097a8;
|
||||
--color-avatar-7: #8d6e63;
|
||||
--color-avatar-8: #5c6bc0;
|
||||
--color-avatar-9: #26a69a;
|
||||
--color-avatar-10: #ef6c00;
|
||||
--color-avatar-11: #9ca2aa;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
|
||||
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
|
||||
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
/* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
|
||||
--shadow-btn: 0 6px 16px rgba(54, 139, 250, 0.2);
|
||||
/* screens/02-messages.dc.html's 36px 发送 button (ConversationView/VoiceRecorder) -- a smaller
|
||||
button gets a smaller shadow, not the same token as the 50px auth buttons above. */
|
||||
--shadow-btn-sm: 0 4px 12px rgba(54, 139, 250, 0.2);
|
||||
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
/* MessageRow's jump-to-message flash (ConversationView, 查找聊天记录 result click) -- a brief
|
||||
background pulse marking the message the user asked to find, fading back to transparent so it
|
||||
doesn't look like a permanent selection state. */
|
||||
@keyframes jump-highlight {
|
||||
0%,
|
||||
30% {
|
||||
background-color: var(--color-accent-soft-bg);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-surface-page);
|
||||
color: var(--color-text-body);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { imPost, imRequest } from './client';
|
||||
|
||||
/**
|
||||
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
|
||||
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
|
||||
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
|
||||
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
|
||||
* 调用失败而跳过后续的本地清理。
|
||||
*/
|
||||
export function logout() {
|
||||
return imPost<void>('/auth/logout');
|
||||
}
|
||||
|
||||
export interface VerificationCodeResponse {
|
||||
challenge_id: string;
|
||||
resend_after_seconds: number;
|
||||
}
|
||||
|
||||
export function requestVerificationCode(scene: 'reset_password' | 'register', phone: string, tenantCode?: string) {
|
||||
return imPost<VerificationCodeResponse>(
|
||||
'/auth/verification-codes',
|
||||
scene === 'register' ? { scene, phone, registration_type: 0, tenant_code: tenantCode } : { scene, phone },
|
||||
{ auth: false },
|
||||
);
|
||||
}
|
||||
|
||||
export interface RegistrationVerifyResponse {
|
||||
verification_token?: string;
|
||||
verification_mode?: 'invite_code';
|
||||
}
|
||||
|
||||
export function verifyRegistrationCode(challengeId: string, code: string) {
|
||||
return imPost<RegistrationVerifyResponse>(
|
||||
`/auth/verification-codes/${encodeURIComponent(challengeId)}/verify`,
|
||||
{ code },
|
||||
{ auth: false },
|
||||
);
|
||||
}
|
||||
|
||||
export interface PasswordResetVerifyResponse {
|
||||
reset_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Password reset verifies through an entirely different endpoint family
|
||||
* (`/auth/password-reset/*`, not `/auth/verification-codes/*`) even though it shares the same
|
||||
* `/auth/verification-codes` code-request endpoint above via `scene`. No Idempotency-Key -- a plain
|
||||
* code check, not a mutation.
|
||||
*/
|
||||
export function verifyPasswordResetCode(challengeId: string, code: string) {
|
||||
return imRequest<PasswordResetVerifyResponse>(
|
||||
`/auth/password-reset/challenges/${encodeURIComponent(challengeId)}/verify`,
|
||||
{ method: 'POST', auth: false, body: { code } },
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmPasswordReset(resetToken: string, newPassword: string) {
|
||||
return imPost<void>(
|
||||
'/auth/password-reset/confirm',
|
||||
{ reset_token: resetToken, new_password: newPassword },
|
||||
{ auth: false },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { imGet } from './client';
|
||||
|
||||
export interface MessagePolicy {
|
||||
supported_actions?: string[];
|
||||
edit_window_seconds?: number;
|
||||
recall_window_seconds?: number;
|
||||
}
|
||||
|
||||
/** README §2.1's "固定置顶两项" -- topic names are fixed/well-known on this backend (confirmed:
|
||||
* docs/IM-前后端对接文档.md:909, `file_assistant.topic:"slf"`, `system_messages.topic:"sys"`), not
|
||||
* per-user values the client has to discover another way. */
|
||||
interface BootstrapEntry {
|
||||
enabled: boolean;
|
||||
route: string;
|
||||
topic?: string;
|
||||
unread_count: number;
|
||||
/** e.g. "topic" (file_assistant) vs "business" (system_messages) -- which side actually owns the
|
||||
* unread count above, confirmed live but not consumed by anything yet. */
|
||||
unread_source?: string;
|
||||
}
|
||||
|
||||
export interface BootstrapConfig {
|
||||
message_policy?: MessagePolicy;
|
||||
limits?: { max_forward_targets?: number; [key: string]: unknown };
|
||||
features?: Record<string, boolean>;
|
||||
permissions?: Record<string, boolean>;
|
||||
modules?: unknown;
|
||||
workbench_version?: unknown;
|
||||
entries?: {
|
||||
new_friends?: BootstrapEntry;
|
||||
groups?: BootstrapEntry;
|
||||
file_assistant?: BootstrapEntry;
|
||||
system_messages?: BootstrapEntry;
|
||||
};
|
||||
entry_badges?: {
|
||||
new_friends_pending_count?: number;
|
||||
groups_unread_count?: number;
|
||||
file_assistant_unread_count?: number;
|
||||
system_unread_count?: number;
|
||||
workbench_total_unread?: number;
|
||||
contacts_total_unread?: number;
|
||||
};
|
||||
system_topic?: { topic: string; type: string };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getBootstrap() {
|
||||
return imGet<BootstrapConfig>('/bootstrap');
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Call REST endpoints (docs/IM-前后端对接文档.md §8.12.1, lines 2066-2076). Confirmed live
|
||||
// (2026-09-08): `POST /v1/im/calls/rtc-join` is real and processes requests meaningfully (a probe
|
||||
// with a made-up call_id returned `404 CALL_NOT_FOUND`, not a generic route-404 or a
|
||||
// field-validation error) -- this is the Agora/声网 RTC path the doc's own closing paragraph
|
||||
// describes for "新客户端" (§8.12.1), NOT the raw-WebRTC/SDP-over-{note} path §0.5.4.7 and §8.1-8.11
|
||||
// otherwise describe throughout -- the old app's own real, shipped call code corroborates that the
|
||||
// raw-WebRTC path is the one that's actually broken in production (its own comments say the
|
||||
// call-invite pub is gated behind a feature flag that currently rejects it). The initial invite
|
||||
// still goes through the SAME `{pub}` message as the raw-WebRTC story (confirmed by the doc's own
|
||||
// sentence: "新客户端在发起方收到 ctrl.params.seq 后...分别调用 rtc-join" -- i.e. rtc-join happens
|
||||
// AFTER and IN ADDITION TO the pub-based invite, not instead of it) -- see send.ts's call-invite
|
||||
// helper. Ringing/accept/hang-up stay on Tinode `{note}` (lightweight state signals, not media
|
||||
// negotiation); only the SDP/ICE exchange is replaced by Agora's own SDK doing media transport.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface RtcJoinResult {
|
||||
provider: string;
|
||||
auth_mode: string;
|
||||
app_id: string;
|
||||
channel_name: string;
|
||||
uid: string;
|
||||
token: string;
|
||||
token_expires_at: string;
|
||||
call_id: string;
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_type: 'audio' | 'video';
|
||||
role: 'caller' | 'callee';
|
||||
}
|
||||
|
||||
export function joinRtcCall(topic: string, callId: string, seq: number, callType: 'audio' | 'video', role: 'caller' | 'callee') {
|
||||
return imPost<RtcJoinResult>('/calls/rtc-join', { topic, call_id: callId, seq, call_type: callType, role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real call `75728`) -- completely different from the doc's own minimal
|
||||
* example: `direction` is a string enum (`"outgoing"`/presumably `"incoming"`), not an `incoming`
|
||||
* boolean; there's no `peer` object at all, just a flat `peer_user_id` (a real `usr...` id, NOT the
|
||||
* `p2p...`-hashed form `topic` itself comes back as -- so unlike favorites.ts's `source.topic`, THIS
|
||||
* field can be resolved to a display name via the chat list); and there are four separate
|
||||
* timestamps (`created_at`/`started_at`/`finished_at`/`updated_at`), no single `ts`.
|
||||
*/
|
||||
export interface CallHistoryItem {
|
||||
topic: string;
|
||||
seq: number;
|
||||
call_id: string;
|
||||
call_type: 'audio' | 'video';
|
||||
direction: 'incoming' | 'outgoing' | string;
|
||||
status: string;
|
||||
result?: string;
|
||||
end_reason?: string;
|
||||
duration_ms: number;
|
||||
caller_id: string;
|
||||
callee_id: string;
|
||||
peer_user_id: string;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
finished_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getCallHistory(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls?${qs.toString()}`);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// Thin REST client for the product-specific `/v1/im/*` API that sits alongside the Tinode WS
|
||||
// protocol. Ported from the previous im_webapp's src/lib/im-api.js -- the header names, the
|
||||
// reason-code translation table, and the batch-endpoint "always 200, real result in items[]"
|
||||
// quirk are all hard-won facts about this exact backend (im_team/im_server, server/im_http.go),
|
||||
// verified against a live deployment over months of integration work. They are not guesses and
|
||||
// should not be "cleaned up" back to what the upstream Tinode docs say (this deployment's header
|
||||
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
|
||||
// a real, repeatedly-rediscovered bug in the old codebase).
|
||||
import { API_KEY } from '@/lib/config';
|
||||
import { getKickedInfo } from '@/lib/tinode/connection';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
const DEFAULT_API_BASE = '/v1/im';
|
||||
const DEFAULT_FILE_BASE = '/v0/file';
|
||||
const CONFIGURED_API_BASE = import.meta.env.VITE_TINODE_API_BASE?.trim();
|
||||
const CONFIGURED_FILE_BASE = import.meta.env.VITE_TINODE_FILE_BASE?.trim();
|
||||
|
||||
export class ImApiError extends Error {
|
||||
reason?: string;
|
||||
status?: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(message: string, reason?: string, status?: number, details?: unknown) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md §12.4's full reason list -- see client.ts's header comment. */
|
||||
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
|
||||
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
|
||||
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
|
||||
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
|
||||
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
|
||||
AUTH_FAILED: '账号或密码错误',
|
||||
TOKEN_EXPIRED: '登录已过期,请重新登录',
|
||||
ACCOUNT_DISABLED: '账号已被禁用',
|
||||
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
||||
VALIDATION_FAILED: '提交的内容不符合要求',
|
||||
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
|
||||
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
|
||||
PERMISSION_DENIED: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
|
||||
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
|
||||
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
|
||||
// internal message or the bare reason string straight to the user for this exact reason code,
|
||||
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
|
||||
FORBIDDEN: '没有权限执行此操作',
|
||||
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
|
||||
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
|
||||
NOT_FOUND: '内容不存在或已被删除',
|
||||
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
|
||||
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
|
||||
AUTH_REQUIRED: '请先登录',
|
||||
ACTION_TOKEN_INVALID: '操作凭证已失效,请重新操作',
|
||||
SCHEMA_UNSUPPORTED: '当前版本过旧,请刷新页面后重试',
|
||||
IDEMPOTENCY_IN_PROGRESS: '上一次操作正在处理中,请稍后再试',
|
||||
INTERNAL_ERROR: '服务器繁忙,请稍后重试',
|
||||
UNSUPPORTED_MEDIA_TYPE: '不支持的文件类型',
|
||||
CHALLENGE_EXPIRED: '验证码已过期,请重新获取',
|
||||
SMS_DELIVERY_FAILED: '短信发送失败,请稍后重试',
|
||||
PASSWORD_POLICY_VIOLATION: '密码不符合安全要求',
|
||||
VERIFICATION_RATE_LIMITED: (details) =>
|
||||
isRetryAfter(details) ? `操作过于频繁,请 ${details.retry_after_seconds} 秒后重试` : '操作过于频繁,请稍后再试',
|
||||
ALREADY_GROUP_MEMBER: '对方已经是群成员',
|
||||
DESCRIPTION_REQUIRED: '请填写简介内容',
|
||||
DESCRIPTION_TOO_LONG: '简介内容过长',
|
||||
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
|
||||
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
|
||||
INVALID_EMAIL: '邮箱格式不正确',
|
||||
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
|
||||
TOO_MANY_FILES: '文件数量超出限制',
|
||||
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
|
||||
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
|
||||
GROUP_MUTED: '你已被禁言,无法发言',
|
||||
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
|
||||
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
|
||||
INVITATION_EXPIRED: '邀请已过期',
|
||||
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
|
||||
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
|
||||
FILE_TOO_LARGE: '文件大小超出限制',
|
||||
FILE_FORBIDDEN: '没有权限访问此文件',
|
||||
FILE_UNAVAILABLE: '文件暂不可用',
|
||||
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
|
||||
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
|
||||
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
|
||||
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
|
||||
QR_CODE_INVALID: '二维码无效,请重新扫描',
|
||||
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
|
||||
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
|
||||
// friends, or a request between the two of you (either direction) is already pending.
|
||||
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
|
||||
USER_NOT_DISCOVERABLE: '未找到该用户',
|
||||
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
|
||||
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
|
||||
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
|
||||
// verification-code verify) -- keep generic enough to be accurate for both.
|
||||
CODE_INVALID: '验证码或独立密码不正确',
|
||||
TOO_MANY_ATTEMPTS: (details) =>
|
||||
isRetryAfter(details) ? `连续输错次数过多,请 ${details.retry_after_seconds} 秒后重试` : '连续输错次数过多,请稍后重试',
|
||||
};
|
||||
|
||||
function isRetryAfter(details: unknown): details is { retry_after_seconds: number } {
|
||||
return !!details && typeof details === 'object' && 'retry_after_seconds' in details;
|
||||
}
|
||||
|
||||
function translateReason(reason: string | undefined, details?: unknown): string | null {
|
||||
if (!reason) return null;
|
||||
const entry = REASON_MESSAGES[reason];
|
||||
return (typeof entry === 'function' ? entry(details) : entry) || null;
|
||||
}
|
||||
|
||||
export function requestId(): string {
|
||||
return typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `web_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function apiBase(): string {
|
||||
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
||||
// Production serves the static web app and IM API from different origins. The configured origin
|
||||
// must also work before a tenant has been resolved, while local development keeps using Vite's
|
||||
// same-origin proxy by leaving this value unset.
|
||||
return (CONFIGURED_API_BASE || endpoints?.api || DEFAULT_API_BASE).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function fileBase(): string {
|
||||
const endpoints = useSessionStore.getState().tenant?.endpoints;
|
||||
// Production may expose the web UI and the IM API through different deployments. Prefer the
|
||||
// build-time canonical file origin so a tenant context previously resolved through the web host
|
||||
// cannot keep routing attachments to that host's stale /v0/file proxy. Local development leaves
|
||||
// this unset and continues to use the resolved relative endpoint through Vite's proxy.
|
||||
return (CONFIGURED_FILE_BASE || endpoints?.file || DEFAULT_FILE_BASE).replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function currentTenantCode(): string {
|
||||
return useSessionStore.getState().tenant?.code || 'default';
|
||||
}
|
||||
|
||||
/** Set by tinode-web.jsx-equivalent login/reconnect code; tab-lifetime, independent of "remember me". */
|
||||
export function authToken(): string {
|
||||
return sessionStorage.getItem('im-session-token') || '';
|
||||
}
|
||||
|
||||
function normalizeResponse(payload: { code?: number; data?: unknown }): unknown {
|
||||
return payload && payload.code === 0 && payload.data !== undefined ? payload.data : payload;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
auth?: boolean;
|
||||
tenantCodeOverride?: string;
|
||||
}
|
||||
|
||||
/** Core request helper. `auth: false` skips the Authorization header (pre-login calls). */
|
||||
export async function imRequest<T = unknown>(
|
||||
path: string,
|
||||
{ method = 'GET', body, headers, auth = true, tenantCodeOverride }: RequestOptions = {},
|
||||
): Promise<T> {
|
||||
const finalHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-MeetingIM-APIKey': API_KEY,
|
||||
'X-IM-Schema': '1',
|
||||
'X-Tenant-Code': tenantCodeOverride || currentTenantCode(),
|
||||
'X-Request-ID': requestId(),
|
||||
...headers,
|
||||
};
|
||||
if (auth) {
|
||||
const token = authToken();
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(apiBase() + path, {
|
||||
method,
|
||||
headers: finalHeaders,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload: { code?: number; data?: unknown; reason?: string; message?: string; details?: unknown } = {};
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new ImApiError(
|
||||
response.ok ? '接口返回非 JSON 数据' : response.statusText || '接口请求失败',
|
||||
'NON_JSON_RESPONSE',
|
||||
response.status,
|
||||
text.slice(0, 160),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok || payload?.code) {
|
||||
const known = translateReason(payload.reason, payload.details);
|
||||
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
|
||||
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
|
||||
// else in this client uniformly handled this before, so a session invalidated mid-use just made
|
||||
// every subsequent call fail the same way forever with no path back to a real login.
|
||||
if (auth && response.status === 401 && authToken()) forceReauth();
|
||||
throw new ImApiError(
|
||||
known || payload.message || payload.reason || response.statusText || '请求失败',
|
||||
payload.reason,
|
||||
response.status,
|
||||
payload.details,
|
||||
);
|
||||
}
|
||||
return normalizeResponse(payload) as T;
|
||||
}
|
||||
|
||||
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
|
||||
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
|
||||
* cache, any open modal's local state) resets from scratch rather than needing each of them to
|
||||
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
|
||||
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
|
||||
* token expires) only navigates once. */
|
||||
let reauthTriggered = false;
|
||||
function forceReauth() {
|
||||
if (reauthTriggered) return;
|
||||
sessionStorage.removeItem('im-session-token');
|
||||
useSessionStore.getState().setMyUserId(null);
|
||||
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
|
||||
reauthTriggered = true;
|
||||
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
|
||||
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
|
||||
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
|
||||
// network timing, not something this code controls). Without this, a REST 401 that happens to
|
||||
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
|
||||
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
|
||||
// that WS message (usually already in flight over an open connection, typically faster than a
|
||||
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
|
||||
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
|
||||
// by far the common case) just navigate ~200ms later than before -- not perceptible.
|
||||
window.setTimeout(() => {
|
||||
if (getKickedInfo()) return;
|
||||
window.location.href = '/login';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const idem = () => ({ 'Idempotency-Key': requestId() });
|
||||
|
||||
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
|
||||
// caller that needs its own extra header can't accidentally drop the Idempotency-Key.
|
||||
export const imGet = <T = unknown>(path: string, opts?: RequestOptions) => imRequest<T>(path, opts);
|
||||
export const imPost = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'POST', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||
export const imPatch = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'PATCH', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||
export const imPut = <T = unknown>(path: string, body?: unknown, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'PUT', body, ...opts, headers: { ...idem(), ...opts.headers } });
|
||||
export const imDelete = <T = unknown>(path: string, opts: RequestOptions = {}) =>
|
||||
imRequest<T>(path, { method: 'DELETE', ...opts, headers: { ...idem(), ...opts.headers } });
|
||||
|
||||
/**
|
||||
* List endpoints here cap out at 20-50 items per page and it's a real, previously-hit bug class to
|
||||
* only ever fetch page 1 (e.g. "am I this group's owner/admin" going undetected because the real
|
||||
* answer was on page 2). Empty/repeated next_cursor means done.
|
||||
*/
|
||||
// Guards against a server-side cursor bug (e.g. two values that cycle back and forth) turning this
|
||||
// into an infinite fetch loop -- 200 pages at a 20-50 item page size is already far more than any
|
||||
// realistic friends/blocks/search list, so hitting this is itself a signal something's wrong server-
|
||||
// side, not a real user's data legitimately needing more.
|
||||
const MAX_PAGES = 200;
|
||||
|
||||
export async function fetchAllPages<T>(
|
||||
fetchPage: (cursor: string) => Promise<{ items?: T[]; next_cursor?: string }>,
|
||||
startCursor = '',
|
||||
): Promise<T[]> {
|
||||
let cursor = startCursor;
|
||||
let items: T[] = [];
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const result = await fetchPage(cursor);
|
||||
items = items.concat(result?.items || []);
|
||||
const nextCursor = result?.next_cursor || '';
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Favorites REST endpoints (docs/IM-前后端对接文档.md ch.7.1-7.8). `favoriteMessages` (the create
|
||||
// side) already lives in messages.ts since it's called from a message's context menu, not from
|
||||
// here -- this file is the read/manage side (list, detail, delete, batch-delete, forward).
|
||||
import type { DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { imDelete, imGet, imPost } from './client';
|
||||
|
||||
export type FavoriteType = 'text' | 'image' | 'video' | 'voice' | 'file' | 'link';
|
||||
|
||||
/**
|
||||
* `snapshot`'s shape is polymorphic per `type`. Confirmed live (2026-09-08, real favorite
|
||||
* `fav_I2yn5Rdux0E`) that a `type:"text"` snapshot is NOT the doc's minimal `{text}` example (line
|
||||
* 1933) -- it's essentially the whole original message: `{content, from, head, seq, text, topic,
|
||||
* ts, type, version}`, i.e. `text`/`content` are duplicates of each other and `seq`/`topic`/`type`
|
||||
* duplicate the outer `source`/`type` fields. Genuinely useful new field: `from` -- a real sender
|
||||
* user id, WITHOUT its `usr` prefix (confirmed: my own id is `usrH5dvZ3ckWxo`, this field came back
|
||||
* as `H5dvZ3ckWxo`) -- a per-field prefix-stripping quirk not seen on any other endpoint in this
|
||||
* app, so re-adding `usr` before matching it against `contacts[].topic` elsewhere is a real but
|
||||
* fragile workaround, not done here yet (see the plan file's Phase 5 findings).
|
||||
*
|
||||
* Confirmed live (2026-09-08, data-rich test account) that this SAME "whole original message"
|
||||
* shape applies to EVERY type, not just `text` -- `image`/`video`/`voice`/`file` all come back as
|
||||
* `{content, from, head, seq, text, topic, ts, type, version}` too, where `content` is a real Drafty
|
||||
* object (never the doc's flat `snapshot.file_id`/`duration_ms`/`snapshot.file{...}` shape). The
|
||||
* actual per-type media data lives nested in `content.ent[].data`, keyed by Drafty entity type
|
||||
* (`tp`): `IM` (image), `VD` (video), `AU` (voice), `EX` (file). Voice/image/video carry their
|
||||
* payload INLINE as base64 in `data.val` (confirmed by decoding the base64 header: voice -> `RIFF`
|
||||
* WAV, image -> PNG signature, video's `val` -> WebM/EBML signature); video ALSO carries a separate
|
||||
* inline base64 JPEG poster frame in `data.preview` (distinct from `val`, which is the actual video
|
||||
* bytes) -- there is no separate thumbnail for image since the image IS the thumbnail. `file` has no
|
||||
* `val` at all, only an external `ref` (`/v0/file/s/{id}`), matching how `EX` already works in real
|
||||
* chat messages. The old flat `snapshot.file_id`/`thumbnail_file_id`/`duration_ms`/`file{...}` fields
|
||||
* below are kept as a defensive fallback (never observed live, but the doc's own worked examples
|
||||
* disagree with its prose here too -- see next paragraph -- so it's cheap insurance, not dead code).
|
||||
*
|
||||
* The doc's own worked examples also disagree with its OWN prose for `type:"file"`: §7.3's text
|
||||
* says file snapshots nest under `snapshot.file{name,size,mime,file_id}`, but the worked JSON
|
||||
* example (line 1936) has `file_id`/`name` FLAT on `snapshot` directly, no nesting -- both shapes
|
||||
* are typed as optional so either renders, trusting the concrete example over the prose.
|
||||
*/
|
||||
export interface Favorite {
|
||||
favorite_id: string;
|
||||
type: FavoriteType;
|
||||
snapshot: {
|
||||
text?: string;
|
||||
content?: DraftyContent | string;
|
||||
from?: string;
|
||||
matches?: Array<{ at: number; len: number }>;
|
||||
thumbnail_file_id?: string;
|
||||
file_id?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
mime?: string;
|
||||
file?: { name: string; size: number; mime: string; file_id: string };
|
||||
duration_ms?: number;
|
||||
waveform?: number[];
|
||||
url?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
risk_level?: string;
|
||||
};
|
||||
source?: { topic: string; seq: number };
|
||||
collected_at: string;
|
||||
availability: 'available' | 'removed' | string;
|
||||
permissions?: { can_copy?: boolean };
|
||||
}
|
||||
|
||||
/** Pulls one Drafty entity's `data` out of a favorite's `snapshot.content` by entity type (`tp`) --
|
||||
* see the `Favorite` doc comment above for why this, not the flat `snapshot.*` fields, is where the
|
||||
* real per-type media data for `image`/`video`/`voice`/`file` actually lives. */
|
||||
export function favoriteEntityData(content: Favorite['snapshot']['content'], tp: string): Record<string, unknown> | undefined {
|
||||
if (!content || typeof content === 'string') return undefined;
|
||||
return content.ent?.find((e) => e.tp === tp)?.data;
|
||||
}
|
||||
|
||||
export function getFavorites(type: FavoriteType | 'all' = 'all', query = '', cursor = '') {
|
||||
const qs = new URLSearchParams({ type });
|
||||
if (query) qs.set('q', query);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: Favorite[]; next_cursor?: string }>(`/favorites?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function deleteFavorite(favoriteId: string) {
|
||||
return imDelete<{ favorite_id: string; deleted: boolean }>(`/favorites/${encodeURIComponent(favoriteId)}`);
|
||||
}
|
||||
|
||||
export function batchDeleteFavorites(favoriteIds: string[]) {
|
||||
return imPost<{ items: Array<{ favorite_id: string; deleted: boolean }>; failed?: Array<{ favorite_id: string; reason: string }> }>(
|
||||
'/favorites/batch-delete',
|
||||
{ favorite_ids: favoriteIds },
|
||||
);
|
||||
}
|
||||
|
||||
export function forwardFavorites(favoriteIds: string[], targetTopics: string[], comment = '') {
|
||||
return imPost<{
|
||||
items: Array<{ favorite_id: string; target_topic: string; status: string; seq?: number }>;
|
||||
failed?: Array<{ favorite_id: string; target_topic: string; reason: string }>;
|
||||
}>('/favorites/forward', { favorite_ids: favoriteIds, target_topics: targetTopics, comment });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Older clients persisted attachment refs on the API host, for example
|
||||
* `https://api.example.com/v0/file/s/...`. Fetching those URLs with the file
|
||||
* auth headers makes the browser forward a CORS preflight through the OSS
|
||||
* redirect. Route them through the tenant's configured file endpoint instead,
|
||||
* matching the relative refs emitted by current clients.
|
||||
*/
|
||||
export function normalizeFileDownloadUrl(
|
||||
refUrl: string,
|
||||
apiEndpoint: string,
|
||||
fileEndpoint: string,
|
||||
pageOrigin: string,
|
||||
): string {
|
||||
try {
|
||||
const source = new URL(refUrl, pageOrigin);
|
||||
const api = new URL(apiEndpoint, pageOrigin);
|
||||
if (source.origin !== api.origin || !source.pathname.startsWith('/v0/file/')) return refUrl;
|
||||
|
||||
const target = new URL(fileEndpoint, pageOrigin);
|
||||
target.pathname = target.pathname.replace(/\/$/, '') + source.pathname.slice('/v0/file'.length);
|
||||
target.search = source.search;
|
||||
target.hash = source.hash;
|
||||
return target.toString();
|
||||
} catch {
|
||||
return refUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Friend/conversation-settings/blocklist REST endpoints. Paths and payload shapes ported from the
|
||||
// old app's src/lib/im-api.js (contact-management-view.jsx's real "聊天设置" panel, not the mock
|
||||
// direct-message-view.jsx) -- verified there, not guessed.
|
||||
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
||||
|
||||
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
|
||||
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
|
||||
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
|
||||
export interface Friend {
|
||||
user_id: string;
|
||||
display_name?: string;
|
||||
remark?: string;
|
||||
avatar_file_id?: string;
|
||||
initial?: string;
|
||||
topic?: string;
|
||||
relationship?: string;
|
||||
online_visibility?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getFriends(query = '', cursor = '', initial = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (query) qs.set('query', query);
|
||||
if (initial) qs.set('initial', initial);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function deleteFriend(userId: string) {
|
||||
return imDelete<void>(`/friends/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export function updateFriendRemark(userId: string, remark: string) {
|
||||
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
|
||||
}
|
||||
|
||||
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
|
||||
* nothing in the response describing what UI affordance to show -- that's derived client-side
|
||||
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
|
||||
export interface UserSearchResult {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
avatar_file_id?: string;
|
||||
public_account?: string;
|
||||
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
|
||||
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
|
||||
// comparison could ever match either real pending state).
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
can_add: boolean;
|
||||
}
|
||||
|
||||
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
|
||||
const qs = new URLSearchParams({ q: query, type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function sendFriendRequest(targetUserId: string, message?: string, source = 'search', sourceTopic?: string) {
|
||||
return imPost<{ request_id: string; topic: string; status: string }>('/friend-requests', {
|
||||
target_user_id: targetUserId,
|
||||
message,
|
||||
source,
|
||||
source_topic: sourceTopic,
|
||||
});
|
||||
}
|
||||
|
||||
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
|
||||
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
|
||||
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
|
||||
export interface FriendRequest {
|
||||
request_id: string;
|
||||
applicant_id: string;
|
||||
target_user_id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
status: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
peer?: { user_id: string; display_name?: string };
|
||||
topic?: string;
|
||||
}
|
||||
|
||||
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
|
||||
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
|
||||
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
|
||||
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
|
||||
* whatever an unfiltered call happens to return. */
|
||||
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
|
||||
const qs = new URLSearchParams({ direction, status });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function acceptFriendRequest(requestId: string, version?: number) {
|
||||
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/accept`, version !== undefined ? { version } : {});
|
||||
}
|
||||
|
||||
export function rejectFriendRequest(requestId: string, version?: number) {
|
||||
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
|
||||
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
|
||||
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
|
||||
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
|
||||
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
|
||||
* between the doc and what this deployment actually returns right now.
|
||||
*/
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
remark?: string;
|
||||
avatar_file_id?: string;
|
||||
gender?: string;
|
||||
region?: string;
|
||||
public_account?: string;
|
||||
signature?: string;
|
||||
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
|
||||
// pending states, never a bare "pending".
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
topic?: string;
|
||||
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
|
||||
}
|
||||
|
||||
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
|
||||
* own user_id (Tinode convention), so DetailColumn can pass `topicId` straight through. */
|
||||
export function getUserProfile(identifier: string) {
|
||||
return imGet<UserProfile>(`/users/${encodeURIComponent(identifier)}/profile`);
|
||||
}
|
||||
|
||||
export interface ConversationSettings {
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** P2P-only -- group pin/mute goes through a different, group-specific mechanism (native Tinode private/tags). */
|
||||
export function getConversationSettings(topic: string) {
|
||||
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
/**
|
||||
* P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on
|
||||
* the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities.
|
||||
* can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28):
|
||||
* sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试"
|
||||
* with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can
|
||||
* never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line
|
||||
* 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows,
|
||||
* so there's no error object left for application code to read once a send has already failed. This
|
||||
* endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after.
|
||||
* `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already
|
||||
* fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of
|
||||
* returning a normal response, so it can't be used for this specific check either.
|
||||
*/
|
||||
export interface ConversationContext {
|
||||
topic: string;
|
||||
peer_user_id: string;
|
||||
display_name: string;
|
||||
can_send: boolean;
|
||||
relationship: 'blocked_by_me' | 'blocked_by_peer' | string;
|
||||
capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean };
|
||||
}
|
||||
|
||||
export function getConversationContext(topic: string) {
|
||||
return imGet<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
|
||||
}
|
||||
|
||||
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
|
||||
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
|
||||
}
|
||||
|
||||
export function blockUser(userId: string) {
|
||||
return imPut<void>(`/blocks/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export function unblockUser(userId: string) {
|
||||
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
|
||||
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
|
||||
* that comes from assuming a 200 means every item succeeded). */
|
||||
export function batchUnblockUsers(userIds: string[]) {
|
||||
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
|
||||
user_ids: userIds,
|
||||
});
|
||||
}
|
||||
|
||||
export interface BlockedUser {
|
||||
user: { user_id: string; display_name?: string; avatar_file_id?: string };
|
||||
blocked_at: string;
|
||||
}
|
||||
|
||||
/** Unlike `/friends`, this endpoint nests the user under `.user` (docs/IM-前后端对接文档.md:1379,1428,
|
||||
* both the prose and the JSON example agree, and the old app's own consumer reads `item.user.
|
||||
* display_name` -- not a flat item like Friend). */
|
||||
export function getBlocks(cursor = '') {
|
||||
return imGet<{ items: BlockedUser[]; next_cursor?: string }>(`/blocks${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
|
||||
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
|
||||
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
|
||||
// instead of the doc where they conflict (see each function's own comment):
|
||||
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
|
||||
// - group-files search param is `q`, not `query`
|
||||
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
|
||||
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
|
||||
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
|
||||
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
|
||||
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
|
||||
import { fetchAllPages } from './client';
|
||||
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
|
||||
|
||||
export interface GroupPermissions {
|
||||
can_edit_profile?: boolean;
|
||||
can_invite_members?: boolean;
|
||||
can_manage_members?: boolean;
|
||||
can_manage_settings?: boolean;
|
||||
can_leave?: boolean;
|
||||
can_mention_all?: boolean;
|
||||
}
|
||||
|
||||
export interface GroupSummaryItem {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
member_count: number;
|
||||
my_role: GroupRole;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (category) qs.set('category', category);
|
||||
if (query) qs.set('query', query);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export interface GroupProfile {
|
||||
topic: string;
|
||||
name: string;
|
||||
avatar_file_id?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
member_count: number;
|
||||
version: number;
|
||||
permissions?: GroupPermissions;
|
||||
}
|
||||
|
||||
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
|
||||
return imPost<GroupProfile>('/groups', body);
|
||||
}
|
||||
|
||||
export function getGroup(topic: string) {
|
||||
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
|
||||
}
|
||||
|
||||
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
|
||||
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
|
||||
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
|
||||
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
|
||||
* example for this response, so this was a guess until checked against the real payload. */
|
||||
export interface GroupGovernance {
|
||||
join_approval: boolean;
|
||||
join_approval_editable?: boolean;
|
||||
mute_all: boolean;
|
||||
mute_all_editable?: boolean;
|
||||
member_upload_files: boolean;
|
||||
member_upload_files_editable?: boolean;
|
||||
member_private_chat: boolean;
|
||||
member_private_chat_editable?: boolean;
|
||||
member_invite: boolean;
|
||||
member_invite_editable?: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
|
||||
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
|
||||
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
|
||||
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
|
||||
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
|
||||
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
|
||||
* file hadn't typed at all until this pass.
|
||||
*/
|
||||
export interface GroupSettings {
|
||||
topic: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
avatar_file_id?: string;
|
||||
owner: string;
|
||||
my_role: GroupRole;
|
||||
permissions?: GroupPermissions;
|
||||
member_count: number;
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
message_retention?: string;
|
||||
governance?: GroupGovernance;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export function getGroupSettings(topic: string) {
|
||||
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
|
||||
}
|
||||
|
||||
export interface GroupMemberSettings {
|
||||
pinned: boolean;
|
||||
mute_notifications: boolean;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
|
||||
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
|
||||
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
|
||||
* locking at all (the server bumps its own stored version unconditionally on every call); the
|
||||
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
|
||||
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
|
||||
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
|
||||
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
|
||||
}
|
||||
|
||||
export function leaveGroup(topic: string) {
|
||||
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
|
||||
}
|
||||
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
|
||||
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
|
||||
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
|
||||
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
|
||||
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
|
||||
* a truly empty body under that header as malformed rather than "no optional fields provided". */
|
||||
export function disbandGroup(topic: string, actionToken: string) {
|
||||
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
|
||||
body: {},
|
||||
headers: { 'X-Action-Token': actionToken },
|
||||
});
|
||||
}
|
||||
|
||||
export type GroupRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
/**
|
||||
* Real, live-confirmed gotcha (2026-09-24): this is NOT always the member's own real name.
|
||||
* Reading server/im_http.go's userSummaryPayload confirms `display_name` here unconditionally
|
||||
* prefers the CALLING viewer's own private remark for that member when one is set, falling back
|
||||
* to their real public nickname only when the viewer has no remark for them -- correct and
|
||||
* intended for rendering a list to yourself (README's member list/search), completely wrong for
|
||||
* anything that gets baked into shared message CONTENT once and read by everyone from then on
|
||||
* (an @mention's label): a real reported bug was exactly this -- a group owner's own private
|
||||
* remark for a member ("小号1") ended up as that member's permanent @mention text, which every
|
||||
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
|
||||
*/
|
||||
display_name?: string;
|
||||
/** Name chosen by this member for this group. */
|
||||
group_nickname?: string;
|
||||
/** The member's own real public profile nickname -- same backend response, but from
|
||||
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
|
||||
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
|
||||
* once baked into shared content (an @mention's label), not just for the viewer's own screen. */
|
||||
nickname?: string;
|
||||
avatar_file_id?: string;
|
||||
role: GroupRole;
|
||||
mentionable?: boolean;
|
||||
eligible?: boolean;
|
||||
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
|
||||
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
|
||||
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
|
||||
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
|
||||
ineligible_reason?: string;
|
||||
acs?: { want?: string; given?: string; mode?: string };
|
||||
}
|
||||
|
||||
export function getGroupMembers(
|
||||
topic: string,
|
||||
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.query) qs.set('query', opts.query);
|
||||
if (opts.cursor) qs.set('cursor', opts.cursor);
|
||||
if (opts.role) qs.set('role', opts.role);
|
||||
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
|
||||
if (opts.mentionable) qs.set('mentionable', 'true');
|
||||
if (opts.muteEligible) qs.set('mute_eligible', 'true');
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
|
||||
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllGroupMembers(
|
||||
topic: string,
|
||||
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
|
||||
) {
|
||||
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
|
||||
}
|
||||
|
||||
/**
|
||||
* `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists
|
||||
* (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a
|
||||
* plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or
|
||||
* themselves. An earlier pass through the protocol doc found no such operation at all -- that was
|
||||
* true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time.
|
||||
*/
|
||||
export function removeGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner;
|
||||
* removing a plain member needs admin-or-better, removing an admin needs the owner specifically.
|
||||
* Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full
|
||||
* list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */
|
||||
export function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean {
|
||||
if (!myUserId || member.user_id === myUserId) return false;
|
||||
if (myRole === 'member') return false;
|
||||
if (member.role === 'owner') return false;
|
||||
if (member.role === 'admin' && myRole !== 'owner') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Dedicated self-profile route. It returns the real optimistic-lock `version` on every read, so
|
||||
* callers do not need to remember the last save's version between edits. */
|
||||
export function getMyGroupProfile(topic: string) {
|
||||
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
|
||||
}
|
||||
|
||||
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
|
||||
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
|
||||
nickname,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGroupRoleLimits(topic: string) {
|
||||
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
|
||||
}
|
||||
|
||||
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
|
||||
return imPost<{
|
||||
succeeded: Array<{ user_id: string; role: GroupRole }>;
|
||||
failed?: Array<{ user_id: string; reason: string }>;
|
||||
admins: GroupMember[];
|
||||
version: number;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
|
||||
}
|
||||
|
||||
export function removeGroupAdmin(topic: string, userId: string, version: number) {
|
||||
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
|
||||
{ headers: { 'If-Match': String(version) } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
|
||||
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
|
||||
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
|
||||
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
|
||||
export interface GroupMute {
|
||||
user_id: string;
|
||||
muted_by?: string;
|
||||
muted_at: string;
|
||||
expires_at?: string | null;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
version?: number;
|
||||
}
|
||||
|
||||
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
|
||||
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
|
||||
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
|
||||
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
|
||||
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
|
||||
`/groups/${encodeURIComponent(topic)}/mutes`,
|
||||
{ members, reason },
|
||||
);
|
||||
}
|
||||
|
||||
function getGroupMutes(topic: string, cursor = '') {
|
||||
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
|
||||
}
|
||||
|
||||
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
|
||||
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
|
||||
* function directly, silently truncating both the list and its own header count once a group has
|
||||
* enough muted members to span more than one page. */
|
||||
export function getAllGroupMutes(topic: string) {
|
||||
return fetchAllPages((cursor) => getGroupMutes(topic, cursor));
|
||||
}
|
||||
|
||||
export function unmuteGroupMember(topic: string, userId: string) {
|
||||
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
|
||||
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
|
||||
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
|
||||
return imPost<{ owner: string; previous_owner: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
|
||||
{ target_user_id: targetUserId, version },
|
||||
{ headers: { 'X-Action-Token': actionToken } },
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupGovernance(topic: string) {
|
||||
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
|
||||
}
|
||||
|
||||
export function updateGroupGovernance(
|
||||
topic: string,
|
||||
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
|
||||
) {
|
||||
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
|
||||
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
|
||||
* target's own privacy setting both don't require approval (added immediately, no further action
|
||||
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
|
||||
* real GroupInvitation record is created instead, delivered to the target as a system notification --
|
||||
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
|
||||
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
|
||||
* `reason`.
|
||||
*/
|
||||
export interface GroupInvitationResult {
|
||||
user_id: string;
|
||||
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
|
||||
invitation_id?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
|
||||
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
|
||||
user_ids: userIds,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The target's own action on a `group.invitation.created` system notification (README/docs
|
||||
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
|
||||
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
|
||||
* immediately on success (no further approval step at this point -- any approval requirement was
|
||||
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
|
||||
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
|
||||
* which this same endpoint resolves).
|
||||
*/
|
||||
export function acceptGroupInvitation(invitationId: string) {
|
||||
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
|
||||
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
|
||||
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
|
||||
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
|
||||
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
|
||||
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
|
||||
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
|
||||
export interface GroupJoinRequest {
|
||||
request_id: string;
|
||||
user_id: string;
|
||||
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
|
||||
* a self-initiated request (search/QR). */
|
||||
inviter_id: string;
|
||||
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
|
||||
message?: string;
|
||||
status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
handled_by?: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
|
||||
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
|
||||
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
|
||||
* "request to join" with no QR code involved would omit both, though this app doesn't have another
|
||||
* UI entry point that reaches a group's join-requests without already being a member (no public
|
||||
* group directory/search exists anywhere in this codebase or its design handoff). */
|
||||
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
|
||||
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
|
||||
const qs = new URLSearchParams({ status });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
|
||||
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
|
||||
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
|
||||
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
|
||||
return imPost<{ request_id: string; status: string; version: number }>(
|
||||
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live: `announcement` is always a present object, even with nothing published --
|
||||
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
|
||||
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
|
||||
export function getGroupSummary(topic: string) {
|
||||
return imGet<{
|
||||
pending_join_request_count: number;
|
||||
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
|
||||
permissions?: GroupPermissions;
|
||||
}>(`/groups/${encodeURIComponent(topic)}/summary`);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
|
||||
* the `published_by` object this file originally guessed -- resolve it against the group's member
|
||||
* list for a display name/role, same as any other bare user id in this API. When nothing has been
|
||||
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
|
||||
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
|
||||
* `content` for emptiness, not object existence.
|
||||
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
|
||||
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
|
||||
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
|
||||
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
|
||||
* omit them. */
|
||||
export interface GroupAnnouncement {
|
||||
announcement_id: string;
|
||||
topic: string;
|
||||
content: string;
|
||||
version: number;
|
||||
published_at: string;
|
||||
author: string;
|
||||
permissions?: { can_edit?: boolean };
|
||||
read_count?: number;
|
||||
member_count?: number;
|
||||
read_summary?: { read_count: number; member_count: number };
|
||||
}
|
||||
|
||||
export function getLatestGroupAnnouncement(topic: string) {
|
||||
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
|
||||
}
|
||||
|
||||
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
|
||||
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
|
||||
}
|
||||
|
||||
export function readGroupAnnouncement(topic: string, announcementId: string) {
|
||||
return imPost<{ announcement_id: string; read_at: string }>(
|
||||
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
|
||||
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
|
||||
* previously declared -- undetected until now because this list only ever rendered its own
|
||||
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
|
||||
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
|
||||
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
|
||||
export interface GroupFile {
|
||||
group_file_id: string;
|
||||
file_id: string;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
uploader_id?: string;
|
||||
created_at: string;
|
||||
download_state?: string;
|
||||
}
|
||||
|
||||
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
|
||||
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
|
||||
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
|
||||
const qs = new URLSearchParams();
|
||||
if (q) qs.set('q', q);
|
||||
if (type) qs.set('type', type);
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
const suffix = qs.toString();
|
||||
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
|
||||
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
|
||||
* set below.
|
||||
*/
|
||||
export function addGroupFile(
|
||||
topic: string,
|
||||
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
|
||||
) {
|
||||
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
|
||||
}
|
||||
|
||||
export function deleteGroupFile(topic: string, groupFileId: string) {
|
||||
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
import { getBootstrap } from './bootstrap';
|
||||
|
||||
/**
|
||||
* Bootstrap config barely changes within a session -- fetch once, keep indefinitely. Keyed by
|
||||
* tenant code, not just `['bootstrap']`, so switching tenants (TenantEntryPage's "切换企业", no full
|
||||
* page reload) fetches the NEW tenant's own message_policy/features/permissions/security instead of
|
||||
* silently keeping the previous tenant's frozen (staleTime: Infinity) response around -- the
|
||||
* queryClient itself is a single app-wide instance that's never cleared on tenant switch.
|
||||
*/
|
||||
export function useBootstrap() {
|
||||
const tenantCode = useSessionStore((s) => s.tenant?.code);
|
||||
return useQuery({ queryKey: ['bootstrap', tenantCode], queryFn: getBootstrap, staleTime: Infinity });
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
|
||||
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
|
||||
// calls this then hard-deletes the message from the local topic cache itself.
|
||||
import type { DraftyContent } from 'tinode-sdk';
|
||||
|
||||
import { imGet, imPost, imPut } from './client';
|
||||
|
||||
export interface RecallResult {
|
||||
topic: string;
|
||||
seq: number;
|
||||
status: string;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This backend tracks its own optimistic-concurrency `version` per message (distinct from Tinode's
|
||||
* `seq` -- confirmed in docs/IM-前后端对接文档.md:1248, `{"version":1,"reason":"..."}` request,
|
||||
* `{"version":2,...}` response), which is NOT delivered anywhere in the native WS `{data}` packet
|
||||
* this app reads messages from (confirmed live: a real message's WS payload carries no `version`
|
||||
* field at all). Since there's currently no source for a message's true current version, this
|
||||
* defaults to 1 (the version of any never-before-edited-or-recalled message, the overwhelmingly
|
||||
* common case) -- a real edit/recall race would surface as a version-conflict error from the
|
||||
* server, which the caller should treat as "refetch and retry," not silently ignore.
|
||||
*/
|
||||
export function recallMessage(topic: string, seq: number, version = 1, reason?: string) {
|
||||
return imPost<RecallResult>(`/topics/${encodeURIComponent(topic)}/messages/${seq}/recall`, { version, reason });
|
||||
}
|
||||
|
||||
interface DraftyEntityData {
|
||||
ref?: string;
|
||||
val?: string;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface DraftyEntity {
|
||||
tp?: string;
|
||||
data?: DraftyEntityData;
|
||||
}
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1879,1889,1898 describe `file`/`link` results as flat `{file:{...}}` /
|
||||
* `{url,title,...}` objects -- confirmed LIVE against the real server to be wrong. The real payload
|
||||
* wraps a `snapshot` of the ORIGINAL raw message (`snapshot.content` is a full `{txt,fmt,ent}`
|
||||
* Drafty document -- pass it to lib/messages/drafty.ts's renderDrafty as-is, don't reconstruct it)
|
||||
* around thin search metadata. `snapshot.text` (not modeled here) is a redundant, sometimes-JSON-
|
||||
* stringified fallback the server fills in for non-text messages -- do not render it.
|
||||
*/
|
||||
export interface SearchHit {
|
||||
seq: number;
|
||||
from: string;
|
||||
ts: string;
|
||||
availability?: string;
|
||||
matches?: Array<{ start: number; end: number }>;
|
||||
snippet?: string;
|
||||
snapshot?: {
|
||||
// A plain, unformatted text message's `content` is confirmed LIVE to arrive as a bare string
|
||||
// (not the `{txt,fmt,ent}` object shape this field was originally typed as) -- the same
|
||||
// string-vs-object duality `renderDrafty` (lib/messages/drafty.tsx) already handles for every
|
||||
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
|
||||
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
|
||||
content?: DraftyContent | string;
|
||||
// Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the
|
||||
// original message's `head`, same as favorites.ts's own snapshot already documents. Needed to
|
||||
// recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by
|
||||
// `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real
|
||||
// Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty`
|
||||
// recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()`
|
||||
// on it). See HistorySearchModal's SnapshotContent for the fix.
|
||||
head?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.4's "查找聊天记录" -- ported endpoint shapes from docs/IM-前后端对接文档.md:1879,1889,1898.
|
||||
* `file`/`link` are this same search endpoint (not the `/media` one below) with an empty `q` -- per
|
||||
* §7.13/7.14 of the protocol doc, only `image`/`video` actually live behind `/media`.
|
||||
*/
|
||||
export function searchTopicMessages(topic: string, query: string, type: 'text' | 'file' | 'link' = 'text', cursor = '') {
|
||||
const qs = new URLSearchParams({ q: query, type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ topic: string; items: SearchHit[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/messages/search?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/** README §3.4's 4-icon grid (图片/视频/文件/链接) -- a UI-level union for "which of the 4 tabs is
|
||||
* selected." Only 2 of the 4 are actually served by `/media` (see MediaFetchType below); file/link
|
||||
* go through searchTopicMessages instead (docs/IM-前后端对接文档.md §7.13-7.16). */
|
||||
export type MediaType = 'image' | 'video' | 'file' | 'link';
|
||||
|
||||
/** The subset of MediaType that `/media` itself understands. */
|
||||
export type MediaFetchType = 'image' | 'video';
|
||||
|
||||
/**
|
||||
* docs/IM-前后端对接文档.md:1907 describes a flat `{seq,thumbnail_file_id,file_id,width,height,ts,
|
||||
* availability}` -- confirmed LIVE to be wrong the same way as SearchHit above: the real payload is
|
||||
* the raw message's own `{ent,fmt,txt}` at the top level (no `snapshot` wrapper this time), so the
|
||||
* actual attachment lives at `ent[0].data`, same DraftyEntityData shape as everywhere else.
|
||||
*/
|
||||
export interface MediaItem {
|
||||
seq: number;
|
||||
from?: string;
|
||||
ts: string;
|
||||
availability: string;
|
||||
ent?: DraftyEntity[];
|
||||
}
|
||||
|
||||
export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '') {
|
||||
const qs = new URLSearchParams({ type });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export interface TopicDraft {
|
||||
topic: string;
|
||||
content: string;
|
||||
entities: unknown[];
|
||||
version: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/
|
||||
* page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as
|
||||
* "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by
|
||||
* reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations.
|
||||
* GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's
|
||||
* never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs
|
||||
* special-casing a not-found error -- confirmed in the same source.
|
||||
*/
|
||||
export function getTopicDraft(topic: string) {
|
||||
return imGet<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`);
|
||||
}
|
||||
|
||||
/** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no
|
||||
* in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */
|
||||
export function putTopicDraft(topic: string, content: string, version: number) {
|
||||
return imPut<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version });
|
||||
}
|
||||
|
||||
/**
|
||||
* A single message's "收藏" context-menu action is really just a one-item batch (docs/
|
||||
* IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->
|
||||
* `{"created_count","existing_count","failed":[]}`. `seq` travels as a string per the documented
|
||||
* example, not a number.
|
||||
*/
|
||||
export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
|
||||
return imPost<{ created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }>('/favorites/batch', {
|
||||
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ForwardResultItem {
|
||||
source?: { topic: string; seq: number };
|
||||
target_topic: string;
|
||||
client_mid: string;
|
||||
seq: number;
|
||||
status: 'sent' | 'pending' | 'uncertain';
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ForwardFailureItem {
|
||||
source?: { topic: string; seq: number };
|
||||
target_topic?: string;
|
||||
status: 'failed';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading
|
||||
* the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path
|
||||
* both real mobile clients use (confirmed by reading their real source too: iOS's
|
||||
* ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds
|
||||
* a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100
|
||||
* sources x 20 targets -- with per-pair success/failure reporting, and the server copies
|
||||
* `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike
|
||||
* the client-side approach this replaces, there is no content-shape detection to get wrong here.
|
||||
* `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep
|
||||
* sending an optional forward comment as its own separate, ordinary text message afterward (matching
|
||||
* this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the
|
||||
* endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the
|
||||
* caller's choice to take on, not something to silently opt into here).
|
||||
*/
|
||||
export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) {
|
||||
return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', {
|
||||
sources,
|
||||
target_topics: targetTopics,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// System notifications REST endpoints (docs/IM-前后端对接文档.md:890-912). Confirmed live field
|
||||
// facts (via a research pass over docs/IM-后端问题清单*.md, 问题30/新增32/33) that diverge from
|
||||
// this doc's own worked example -- see each item's comment below.
|
||||
import { imGet, imPost } from './client';
|
||||
|
||||
export interface SystemNotification {
|
||||
id: string;
|
||||
/** Real values seen include the `friend.*`/`group.*`/`message.*`/`meeting.*`/`security.risk.*`
|
||||
* family from the WS business-event table (doc lines 408-420) -- route on this, not `action.type`
|
||||
* (see below). */
|
||||
event_type: string;
|
||||
title: string;
|
||||
/** Confirmed live (问题30): this is sometimes a raw machine code (`"accepted"`,
|
||||
* `"security.risk.cancel"`) instead of human text, and can be empty. Never render blindly --
|
||||
* always run through NOTIFICATION_TEXT first. */
|
||||
summary: string;
|
||||
/**
|
||||
* Confirmed live (问题30) the shape varies by source and does NOT reliably match this doc's own
|
||||
* worked example (`{"type":"friend_request","request_id":"frq_1"}`) -- a real friend-request
|
||||
* notification's `action` was just `{"user_id":"usr..."}`, no `type` key at all. Group events:
|
||||
* `{topic}`. Meeting events: inconsistently either `{module,resource_id}` or `{meeting_id}`.
|
||||
* New-device-login's documented `{device_name,platform,ip_masked,login_at,security_action}`
|
||||
* shape (doc line 891) is confirmed NOT IMPLEMENTED server-side at all (新增12, still open) --
|
||||
* never assume these fields are populated.
|
||||
*/
|
||||
action?: {
|
||||
type?: string;
|
||||
request_id?: string;
|
||||
topic?: string;
|
||||
user_id?: string;
|
||||
device?: string;
|
||||
ip?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function getSystemNotifications(cursor = '', limit = 20) {
|
||||
const qs = new URLSearchParams({ limit: String(limit) });
|
||||
if (cursor) qs.set('cursor', cursor);
|
||||
return imGet<{ items: SystemNotification[]; next_cursor?: string }>(`/system-notifications?${qs.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmed live (新增32, still open): marking an ALREADY-read notification echoes back whatever
|
||||
* `read_at` you send as if it succeeded, but the stored value stays frozen at the first real write
|
||||
* -- the response lies about updating it. Never treat this response's `read_at` as authoritative;
|
||||
* only use it to flip local `read_at` truthy/falsy, matching the old app's own workaround.
|
||||
*/
|
||||
export function markSystemNotificationRead(id: string, readAt = new Date().toISOString()) {
|
||||
return imPost<{ status: string; id: string; read_at: string }>(`/system-notifications/${encodeURIComponent(id)}/read`, { read_at: readAt });
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Own-profile/privacy REST endpoints (docs/IM-前后端对接文档.md ch.10, lines 2281-2397). Confirmed
|
||||
// against the doc's own worked examples (table lines 2490-2494) and, where the doc has no example
|
||||
// at all, against the rewritten old app's real working code (im_webapp/src/views/personal-settings-
|
||||
// view.jsx) -- flagged per-field below.
|
||||
import { imGet, imPatch } from './client';
|
||||
|
||||
/** `GET /v1/im/me` -- thin summary for a home/header card. Distinct from `getMyProfile` below (the
|
||||
* full profile-edit source) -- confirmed live these are two separate endpoints with different
|
||||
* field sets, not one response read two ways. */
|
||||
export interface MeSummary {
|
||||
user_id: string;
|
||||
profile: { public_account: string; nickname: string; avatar_file_id?: string; signature?: string; version: number };
|
||||
profile_version: number;
|
||||
}
|
||||
|
||||
export function getMe() {
|
||||
return imGet<MeSummary>('/me');
|
||||
}
|
||||
|
||||
export interface MyProfile {
|
||||
avatar_file_id?: string;
|
||||
nickname: string;
|
||||
birth_date?: string;
|
||||
signature?: string;
|
||||
gender?: 'male' | 'female' | 'other' | string;
|
||||
region?: string;
|
||||
email?: string;
|
||||
public_account: string;
|
||||
version: number;
|
||||
field_rules?: { nickname?: { min_length: number; max_length: number }; signature?: { max_length: number } };
|
||||
}
|
||||
|
||||
export function getMyProfile() {
|
||||
return imGet<MyProfile>('/me/profile');
|
||||
}
|
||||
|
||||
/** doc.md:2492 only shows nickname/signature in its worked example; gender/region have no doc
|
||||
* example at all but the old app's real, working code PATCHes them the identical way (`gender`
|
||||
* enum confirmed `male|female|other`, personal-settings-view.jsx:214). */
|
||||
export function updateMyProfile(patch: Partial<Pick<MyProfile, 'nickname' | 'birth_date' | 'signature' | 'gender' | 'region' | 'avatar_file_id' | 'email'>> & { version: number }) {
|
||||
return imPatch<MyProfile>('/me/profile', patch);
|
||||
}
|
||||
|
||||
export interface AddMeMethods {
|
||||
account: boolean;
|
||||
phone: boolean;
|
||||
qr: boolean;
|
||||
group: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `profile_visibility`/`last_seen_visibility` enum confirmed as `everyone|friends|nobody` only via
|
||||
* the old app's own code comment (the doc's worked examples only ever show `"friends"`) -- the
|
||||
* server's validator 400s `contacts`/`hidden` outright, don't offer those as options.
|
||||
*
|
||||
* There is NO "加我为好友时需要验证" field anywhere in this response -- confirmed absent both in
|
||||
* the doc and in the old app's own `UserPrivacySettings` research (personal-settings-view.jsx:534-
|
||||
* 538: the design reference shows a control for it, but neither the response shape nor the group-
|
||||
* invite backend logic has anything to gate). Don't build that toggle.
|
||||
*/
|
||||
export interface PrivacySettings {
|
||||
add_me_methods: AddMeMethods;
|
||||
profile_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
last_seen_visibility: 'everyone' | 'friends' | 'nobody' | string;
|
||||
read_receipts: boolean;
|
||||
version: number;
|
||||
editable?: Partial<Record<keyof PrivacySettings, boolean>>;
|
||||
}
|
||||
|
||||
export function getMyPrivacy() {
|
||||
return imGet<PrivacySettings>('/me/privacy');
|
||||
}
|
||||
|
||||
export function updateMyPrivacy(
|
||||
patch: Partial<{ add_me_methods: Partial<AddMeMethods>; profile_visibility: string; last_seen_visibility: string; read_receipts: boolean }> & {
|
||||
version: number;
|
||||
},
|
||||
) {
|
||||
return imPatch<PrivacySettings>('/me/privacy', patch);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// QR code REST endpoints (docs/IM-前后端对接文档.md §5.15.1). Confirmed LIVE against the real
|
||||
// deployment (2026-09-08, two fresh test accounts) -- `resolveQRCode`'s real response shape nests
|
||||
// `relationship`/`can_add`/`action`/`public_account` all under `user`, unlike the doc's own worked
|
||||
// example which shows them as siblings of `user` at the top level. Also confirmed live: resolving a
|
||||
// code against the SAME account that minted it is rejected (`400 MALFORMED_REQUEST`, `field:"token"`,
|
||||
// message "malformed request" -- distinct from a genuinely garbage token, which instead gets message
|
||||
// "qr code invalid" with no `details.field` at all) -- cross-account resolution works correctly.
|
||||
//
|
||||
// The security rationale for why this exists at all instead of just encoding a raw user id (doc line
|
||||
// 1299): "二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录
|
||||
// Token" -- the QR image payload is `qr_payload` (an `im://qr?token=...&purpose=...` URI), not
|
||||
// `qr_token` alone, matching the old app's own `im://qr` scheme (im_webapp/src/views/tinode-web.jsx
|
||||
// confirmed this exact prefix check before ever sending a scanned string to `resolve`).
|
||||
import { imPost } from './client';
|
||||
|
||||
interface QRCodeDisplayProfile {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
}
|
||||
|
||||
export interface MyQRCode {
|
||||
qr_token: string;
|
||||
/** What actually gets encoded into the QR image -- `im://qr?token=...&purpose=...`. */
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
display_profile: QRCodeDisplayProfile;
|
||||
}
|
||||
|
||||
export function createMyQRCode(expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>('/me/qrcodes', { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
/** `identifier`: user_id/username/public_account -- viewing a specific friend's own QR card. */
|
||||
export function createUserQRCode(identifier: string, expiresInSeconds = 86400) {
|
||||
return imPost<MyQRCode>(`/users/${encodeURIComponent(identifier)}/qrcodes`, { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
|
||||
export interface QRResolveUser {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
public_account?: string;
|
||||
// docs/IM-前后端对接文档.md:1289 -- same real direction-specific enum as friends.ts's
|
||||
// UserSearchResult/UserProfile, never a bare "pending".
|
||||
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
|
||||
can_add: boolean;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Confirmed LIVE (2026-09-08, same account that minted the code -- a real cross-account "scan a
|
||||
* stranger's group code" resolve hasn't been exercised, this session only had one account free):
|
||||
* a `purpose:"join_group"` resolve nests the group under `group` (NOT `group_snapshot`, despite
|
||||
* the mint response using that key -- resolve and mint use different field names for the same
|
||||
* shape), and carries a `status` this specific case's doc example (line 1737, mint-only) never
|
||||
* showed: resolving your own group's code (i.e. already a member) comes back `status:"joined"`.
|
||||
* What a genuinely-not-yet-a-member resolve returns for `status` is unconfirmed -- treated as
|
||||
* "anything other than 'joined'" in QRScannerModal/AddFriendModal, i.e. the general case that still
|
||||
* needs a real join-request submission. */
|
||||
export interface QRResolveGroup {
|
||||
topic: string;
|
||||
name: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
export interface QRResolveResult {
|
||||
purpose: 'add_friend' | 'join_group' | string;
|
||||
source: string;
|
||||
source_topic: string;
|
||||
expires_at: string;
|
||||
user?: QRResolveUser;
|
||||
group?: QRResolveGroup;
|
||||
group_snapshot?: QRResolveGroup;
|
||||
/** join_group only -- `"joined"` means the scanning account is already a member (confirmed live);
|
||||
* any other value (including absent) is treated as "not a member yet, submit a join request". */
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export function resolveQRCode(token: string, purpose: 'add_friend' | 'join_group' | string = 'add_friend') {
|
||||
return imPost<QRResolveResult>('/qrcodes/resolve', { token, purpose });
|
||||
}
|
||||
|
||||
/** docs/IM-前后端对接文档.md:1590 -- 群管理员/群主展示的入群二维码 (mirrors createMyQRCode/
|
||||
* createUserQRCode's shape, just scoped to a group and carrying `group_snapshot` instead of a
|
||||
* personal `display_profile`). */
|
||||
export interface GroupQRCode {
|
||||
qr_token: string;
|
||||
qr_payload: string;
|
||||
expires_at: string;
|
||||
group_snapshot: QRResolveGroup;
|
||||
}
|
||||
|
||||
export function createGroupQRCode(topic: string, expiresInSeconds = 86400) {
|
||||
return imPost<GroupQRCode>(`/groups/${encodeURIComponent(topic)}/qrcodes`, { expires_in_seconds: expiresInSeconds });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user