Compare commits
40
Commits
0d32569633
...
e3217a5de9
+17
-16
@@ -1,14 +1,21 @@
|
||||
# ---- Stage 1: build static assets ----
|
||||
# The frontend was fully rewritten from scratch under web/ (Vite + React + TS) on branch
|
||||
# redesign/desktop-multicolumn, merged into dev 2026-09-09 -- this replaces the old webpack build
|
||||
# that used to live at the repo root (umd/, css/base.min.css, index.html, etc.). See web/README-
|
||||
# equivalent context in the redesign plan for why: this Dockerfile previously never touched web/ at
|
||||
# all, so every "deploy" kept shipping the old app regardless of what was on dev.
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
COPY web/. .
|
||||
|
||||
# TINODE_API_KEY is read by webpack.config.js (via DefinePlugin) at build time.
|
||||
# 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 TINODE_API_KEY=$TINODE_API_KEY
|
||||
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -19,17 +26,11 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
COPY --from=build /app/umd ./umd
|
||||
COPY --from=build /app/css/base.min.css ./css/base.min.css
|
||||
COPY --from=build /app/index.html ./index.html
|
||||
COPY --from=build /app/index-dev.html ./index-dev.html
|
||||
COPY --from=build /app/manifest.json ./manifest.json
|
||||
COPY --from=build /app/service-worker.js ./service-worker.js
|
||||
COPY --from=build /app/version.js ./version.js
|
||||
COPY --from=build /app/img ./img
|
||||
COPY --from=build /app/audio ./audio
|
||||
# Push notifications are not configured for this deployment yet.
|
||||
RUN echo "" > ./firebase-init.js
|
||||
# Vite's build output is fully self-contained: index.html + favicon.svg/icons.svg (from web/public/,
|
||||
# copied verbatim) + assets/ (content-hashed JS/CSS chunks) -- no separate umd/css/img/audio/
|
||||
# manifest/service-worker/firebase files the way the old build had, none of that machinery exists
|
||||
# in the new app.
|
||||
COPY --from=build /app/web/dist/. ./
|
||||
|
||||
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
|
||||
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
||||
|
||||
+103
-14
@@ -133,15 +133,18 @@
|
||||
--clr-input-invalid-shadow: light-dark(#e5737340, #1a0c0c40);
|
||||
--clr-input-placeholder-text: var(--clr-text-placeholder);
|
||||
|
||||
/* Chat Bubbles */
|
||||
--clr-bubble-left-bg: light-dark(#c4e0a3, #3e5c1f);
|
||||
--clr-bubble-left-bg-flash: light-dark(#94a97c, #2c4517);
|
||||
--clr-bubble-right-bg: var(--clr-background-off-white);
|
||||
--clr-bubble-right-bg-flash: var(--clr-border-light);
|
||||
/* Chat Bubbles -- docs/images/个人对话框(我的状态).png 与 ...(对方的状态).png:发出=蓝色实心+白字,
|
||||
收到=浅灰+深字,这两份参考图和之前所有相关截图口径一致;旧值(收到浅绿#c4e0a3、发出用页面同款
|
||||
off-white)不匹配任何一张参考图,是尚未跟设计稿核对过的原始默认值,这次改成实际设计要求的配色。 */
|
||||
--clr-bubble-left-bg: light-dark(#f0f0f0, #2c2c2e);
|
||||
--clr-bubble-left-bg-flash: light-dark(#d8d8d8, #3a3a3c);
|
||||
--clr-bubble-right-bg: var(--clr-primary-accent);
|
||||
--clr-bubble-right-bg-flash: var(--clr-primary-accent-hover);
|
||||
--clr-bubble-text: light-dark(#212121, #bbc);
|
||||
--clr-bubble-right-text: #fff;
|
||||
--clr-bubble-meta-text: var(--clr-text-primary);
|
||||
--clr-bubble-meta-deleted-bg: var(--clr-primary-wash);
|
||||
--clr-bubble-meta-date-bg: light-dark(rgba(255 255 255 / 80%), rgba(40 40 40 / 80%));
|
||||
--clr-bubble-meta-date-bg: transparent;
|
||||
--clr-bubble-timestamp-text: light-dark(#777, #888);
|
||||
--clr-bubble-author-text: light-dark(#777, #666);
|
||||
|
||||
@@ -3581,10 +3584,12 @@ img.channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0.75rem 0;
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--clr-bright);
|
||||
border-bottom: 1px solid var(--clr-divider);
|
||||
color: var(--clr-text-primary);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
color: var(--clr-bubble-text);
|
||||
flex: none;
|
||||
}
|
||||
#group-announcement-banner i:first-child { color: var(--clr-warning-text, #e0a82e); }
|
||||
@@ -4581,6 +4586,11 @@ video {
|
||||
transition: ease-out background-color 0.5s;
|
||||
transition-delay: 0.3s;
|
||||
filter: drop-shadow(0.5px 0.5px 0.7px var(--clr-shadow-bubble-drop));
|
||||
/* iOS Safari's press-and-hold callout (Copy/Look Up/Share...) is a separate mechanism from the
|
||||
`contextmenu` DOM event suppressed in chat-message.jsx -- needs its own opt-out so it doesn't
|
||||
stack with our own long-press menu. Doesn't touch user-select, so normal click-drag text
|
||||
selection on desktop is untouched. */
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
.chat-box li.single {
|
||||
@@ -4618,6 +4628,7 @@ video {
|
||||
|
||||
.right .bubble {
|
||||
background-color: var(--clr-bubble-right-bg);
|
||||
color: var(--clr-bubble-right-text);
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
|
||||
@@ -4625,6 +4636,18 @@ video {
|
||||
background-color: var(--clr-bubble-right-bg-flash);
|
||||
}
|
||||
|
||||
/* Same collision as the read-receipt checkmark below: the global `a` rule (css/base.css:1970)
|
||||
colors links with --clr-primary-accent, which is now this bubble's own background -- an
|
||||
auto-linked URL in a sent message rendered with 0 contrast, invisible text that was still
|
||||
very much there in the DOM (confirmed live: a real <a href> with the full URL as its text).
|
||||
Underline is what marks it as a link now that color can't. */
|
||||
.right .bubble a,
|
||||
.right .bubble a:hover,
|
||||
.right .bubble a:focus {
|
||||
color: var(--clr-bubble-right-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.right .bubble.tip {
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
@@ -4774,6 +4797,22 @@ Font from 3x to almost normal, from 1 to 5 emojis in a message */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* --clr-bubble-timestamp-text is a mid-gray tuned for the light-gray received bubble; on the new
|
||||
solid-blue sent bubble it reads as too low-contrast, so the sent side gets its own translucent
|
||||
white instead of a second global token. */
|
||||
.right .bubble .timestamp {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
/* deliveryMarker()'s "read" checkmark uses the .blue utility class (lib/utils.js), which resolves
|
||||
to --clr-primary-accent -- the exact same blue this bubble's background now is. Left as-is, a
|
||||
read receipt on your own message would render an invisible checkmark. Opaque white keeps it a
|
||||
shade brighter than the translucent timestamp text next to it, so "read" still reads as distinct
|
||||
from plain "sent". */
|
||||
.right .bubble .timestamp .blue {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* The name of the person who sent the message
|
||||
* (group chat only)
|
||||
*/
|
||||
@@ -7658,8 +7697,11 @@ div.toast.show {
|
||||
}
|
||||
|
||||
.system-notification-load-more {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid #eceef1;
|
||||
@@ -10996,8 +11038,11 @@ div.toast.show {
|
||||
}
|
||||
|
||||
.library-load-more {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: min(100%, 52rem);
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
padding: 0.8rem;
|
||||
border: 1px solid #eceef1;
|
||||
@@ -11767,6 +11812,7 @@ div.toast.show {
|
||||
align-items: start;
|
||||
gap: 0.8rem;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 5.5rem;
|
||||
margin: 0;
|
||||
padding: 0.8rem 1rem;
|
||||
@@ -11784,10 +11830,22 @@ div.toast.show {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The global button{} reset's height:2.2rem clamps up to this row's own min-height (5.5rem) but
|
||||
stays a DEFINITE height rather than becoming auto -- so a snippet long enough to wrap past what
|
||||
5.5rem holds (any real search hit, not just short mock text) doesn't grow the row to fit; it
|
||||
overflows past the row's own border-bottom into the next entry's space instead (confirmed live:
|
||||
this row's own trailing <small> timestamp rendered overlapping the NEXT row's sender name).
|
||||
height:auto above lets the row grow past min-height like a plain block would. Same reset also
|
||||
sets white-space:nowrap, which the snippet <p> inherits -- with no wrapping at all, anything past
|
||||
the column width (a long link, not just long plain text) runs off the right edge instead of
|
||||
wrapping. overflow-wrap:anywhere matches .bubble div.message-content's own handling of the same
|
||||
kind of long, space-free URL text. */
|
||||
.library-message-results p {
|
||||
margin: 0.3rem 0;
|
||||
color: #666e78;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.library-message-results mark {
|
||||
@@ -13427,6 +13485,23 @@ div.toast.show {
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.change-password-fields label {
|
||||
display: block;
|
||||
width: min(100%, 26rem);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.change-password-fields label span {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.change-password-fields label input {
|
||||
height: 3.2rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.personal-qr-page > main {
|
||||
padding: 1rem;
|
||||
}
|
||||
@@ -14491,8 +14566,7 @@ div.toast.show {
|
||||
margin: 0;
|
||||
padding: 0.75rem 0.8rem 2rem;
|
||||
resize: vertical;
|
||||
border: 1px solid #dfe3e7;
|
||||
border-radius: 0.35rem;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
@@ -14574,8 +14648,7 @@ div.toast.show {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.evidence-upload,
|
||||
.feedback-upload > button {
|
||||
.evidence-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -14591,6 +14664,22 @@ div.toast.show {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.feedback-upload > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 7.5rem;
|
||||
height: 7.5rem;
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: none;
|
||||
color: #858d96;
|
||||
background: #fff;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.evidence-upload .material-icons,
|
||||
.feedback-upload > button .material-icons {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+11
-11
@@ -5,20 +5,20 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Tinode webapp uses hash-based client-side routing, so any unknown path
|
||||
# can safely fall back to 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;
|
||||
}
|
||||
|
||||
# Filenames here aren't content-hashed, so a long/immutable cache would keep
|
||||
# serving a stale bundle after every deploy. Force revalidation instead:
|
||||
# cheap 304s when unchanged, immediate pickup of new content when not.
|
||||
location /umd/ {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location /img/ {
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
# 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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
<head>
|
||||
<title>MeetingIM</title>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Real (History API) routing means the address bar can show a deep, multi-segment path like
|
||||
/messages-home/grpXXX -- every asset reference below is a relative path (css/base.css,
|
||||
umd/index.dev.js, etc.), which would otherwise resolve against that deep path instead of the
|
||||
app's actual root on a deep-linked load or a refresh, 404ing everything. This pins relative
|
||||
resolution to root regardless of the current path. -->
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="MeetingIM, version for developers" />
|
||||
<meta name="description" content="MeetingIM instant messenger, development version" />
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
<head>
|
||||
<title>MeetingIM</title>
|
||||
<meta charset="utf-8" />
|
||||
<!-- Real (History API) routing means the address bar can show a deep, multi-segment path like
|
||||
/messages-home/grpXXX -- every asset reference below is a relative path (css/base.min.css,
|
||||
umd/index.prod.js, etc.), which would otherwise resolve against that deep path instead of the
|
||||
app's actual root on a deep-linked load or a refresh, 404ing everything. This pins relative
|
||||
resolution to root regardless of the current path. -->
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="application-name" content="MeetingIM" />
|
||||
<meta name="description" content="MeetingIM instant messenger in a browser" />
|
||||
|
||||
+9
-4
@@ -131,7 +131,12 @@ self.addEventListener('notificationclick', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlHash = '#/' + data.topic;
|
||||
// '_' mirrors the placeholder Navigation.composeUrl (src/lib/navigation.js) substitutes for an
|
||||
// empty path[0] ("no sidepanel view, just open this topic") -- a real empty first segment would
|
||||
// make the path start with '//', which pushState/URL resolve as a protocol-relative reference
|
||||
// (host becomes "data.topic") rather than a same-origin path. This file runs outside the webpack
|
||||
// bundle (importScripts, not an ES module) so it can't import the constant; kept in sync by hand.
|
||||
const urlPath = '/_/' + data.topic;
|
||||
|
||||
event.waitUntil(self.clients.matchAll({
|
||||
type: 'window',
|
||||
@@ -141,7 +146,7 @@ self.addEventListener('notificationclick', event => {
|
||||
let anyClient = null;
|
||||
for (let i = 0; i < windowClients.length; i++) {
|
||||
const url = new URL(windowClients[i].url);
|
||||
if (url.hash.includes(data.topic)) {
|
||||
if (url.pathname.includes(data.topic)) {
|
||||
// Found the Tinode tab with the right topic open.
|
||||
return windowClients[i].focus();
|
||||
} else {
|
||||
@@ -154,7 +159,7 @@ self.addEventListener('notificationclick', event => {
|
||||
// navigate to the right topic.
|
||||
if (anyClient) {
|
||||
const url = new URL(anyClient.url);
|
||||
url.hash = urlHash;
|
||||
url.pathname = urlPath;
|
||||
return anyClient.focus().then(thisClient => {
|
||||
return thisClient.navigate(url);
|
||||
});
|
||||
@@ -162,7 +167,7 @@ self.addEventListener('notificationclick', event => {
|
||||
|
||||
// Did not find a Tinode browser tab. Open one.
|
||||
const url = new URL(self.location.origin);
|
||||
url.hash = urlHash;
|
||||
url.pathname = urlPath;
|
||||
return clients.openWindow(url);
|
||||
}));
|
||||
});
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import { createRoot } from 'react-dom/client';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
|
||||
import TinodeWeb from './views/tinode-web.jsx';
|
||||
import HashNavigation from './lib/navigation.js';
|
||||
import Navigation from './lib/navigation.js';
|
||||
|
||||
// Insert google analytics script and tag if configured.
|
||||
if (typeof FIREBASE_INIT != 'undefined' && FIREBASE_INIT && FIREBASE_INIT.measurementId) {
|
||||
@@ -45,7 +45,7 @@ const rtl = ['ar', 'fa', 'he', 'ur'];
|
||||
|
||||
// Detect human language to use in the UI:
|
||||
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
|
||||
const { params } = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const { params } = Navigation.parseUrl(Navigation.currentUrl());
|
||||
const language = (params && params.hl) ||
|
||||
(navigator.languages && navigator.languages[0]) ||
|
||||
navigator.language ||
|
||||
|
||||
+79
-44
@@ -1,7 +1,7 @@
|
||||
// Utility class for hash navigation.
|
||||
// Utility class for client-side routing via the History API.
|
||||
|
||||
// Every `navigateTo` call assigns `location.hash`, which the browser already records as a normal
|
||||
// history entry -- `history.back()` would land on whatever the user actually saw before, for free.
|
||||
// Every `navigateTo` call pushes a real history entry -- `history.back()` would land on whatever
|
||||
// the user actually saw before, for free.
|
||||
// The product's "back" buttons never used that: each page hardcodes a single destination view name
|
||||
// (e.g. Profile's `back={friend ? 'friend-profile' : 'chat-standard'}`), which is only ever right for
|
||||
// the one entry point its author had in mind. Reach the same page from a second place (search result
|
||||
@@ -12,16 +12,28 @@
|
||||
// counter is in-memory and intentionally does not survive those).
|
||||
let navigationCount = 0;
|
||||
|
||||
export default class HashNavigation {
|
||||
// Parse hash as in http://www.example.com/path#hash as if it were
|
||||
// path and arguments.
|
||||
static parseUrlHash(hash) {
|
||||
// Placeholder path segment for "no sidepanel view selected, just a topic" (path[0] === '', the last
|
||||
// entry in tinode-web.jsx's own SIDE_PANEL_VIEWS whitelist). A real empty path segment can't be
|
||||
// represented safely as the FIRST segment of a real URL path: joining ['', 'grpXXX'] would produce
|
||||
// "/grpXXX" before the leading '/' this class adds is even accounted for, so the final string comes
|
||||
// out "//grpXXX" -- and a path starting with '//' is resolved by pushState/replaceState as a
|
||||
// protocol-relative reference (same scheme, host becomes "grpXXX"), not a same-origin path. Swapping
|
||||
// in this token keeps composeUrl/parseUrl round-tripping path[0] === '' exactly like before, entirely
|
||||
// inside this file -- no caller needs to know the placeholder exists.
|
||||
const EMPTY_VIEW_PLACEHOLDER = '_';
|
||||
|
||||
export default class Navigation {
|
||||
// Parse a route string (path + optional '?args') into path segments and params.
|
||||
static parseUrl(url) {
|
||||
// Split 'path/?args' into 'path/' and 'args', path -> parts[0], args->path[1].
|
||||
const parts = hash.split('?', 2);
|
||||
const parts = url.split('?', 2);
|
||||
const params = {};
|
||||
let path = [];
|
||||
if (parts[0]) {
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
path = parts[0].replace(/^\//, '').split('/');
|
||||
if (path.length > 1 && path[0] === EMPTY_VIEW_PLACEHOLDER) {
|
||||
path[0] = '';
|
||||
}
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach((arg) => {
|
||||
@@ -35,34 +47,45 @@ export default class HashNavigation {
|
||||
return {path: path, params: params};
|
||||
}
|
||||
|
||||
// The current route string, in the same shape `navigateTo`/`navigateReplace` consume and
|
||||
// `parseUrl` expects -- callers that used to read `window.location.hash` directly read this
|
||||
// instead (this class never wrapped that read itself, so every call site has to switch explicitly).
|
||||
static currentUrl() {
|
||||
return window.location.pathname + window.location.search;
|
||||
}
|
||||
|
||||
static navigateTo(url) {
|
||||
navigationCount++;
|
||||
window.location.hash = url;
|
||||
window.history.pushState(null, '', url);
|
||||
// pushState never fires popstate on its own (only real back/forward/go() does) -- dispatch a
|
||||
// synthetic one so tinode-web.jsx's listener re-derives state from the new URL, same trick
|
||||
// navigateReplace below already uses for replaceState's identical silence.
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// For switching between sub-tabs of the *same* page (e.g. content-library-view.jsx's favorites/
|
||||
// history type tabs), not for navigating to a different page. `navigateTo`/`location.hash =`
|
||||
// always pushes a new history entry, so clicking through N tabs pushes N entries -- the system
|
||||
// back button (and this class's own goBack()) then has to step back through every tab the user
|
||||
// ever visited before it reaches whatever page they were actually on before this one. `replaceState`
|
||||
// updates the hash (still correct for a refresh or a copied link) without adding a history entry,
|
||||
// so it doesn't inflate navigationCount either -- a tab switch was never a real in-app navigation
|
||||
// to begin with. replaceState doesn't fire `hashchange` on its own, so dispatch one manually for
|
||||
// the existing `window.addEventListener('hashchange', ...)` listener (tinode-web.jsx's
|
||||
// handleHashRoute) to pick up; that handler re-reads window.location.hash itself rather than the
|
||||
// event, so a synthetic event with no detail is indistinguishable from a real one to it.
|
||||
// history type tabs), not for navigating to a different page. `navigateTo` always pushes a new
|
||||
// history entry, so clicking through N tabs pushes N entries -- the system back button (and this
|
||||
// class's own goBack()) then has to step back through every tab the user ever visited before it
|
||||
// reaches whatever page they were actually on before this one. `replaceState` updates the URL
|
||||
// (still correct for a refresh or a copied link) without adding a history entry, so it doesn't
|
||||
// inflate navigationCount either -- a tab switch was never a real in-app navigation to begin with.
|
||||
// replaceState doesn't fire `popstate` on its own, so dispatch one manually for the existing
|
||||
// `window.addEventListener('popstate', ...)` listener (tinode-web.jsx's handleRouteChange) to pick
|
||||
// up; that handler re-reads the current URL itself rather than the event, so a synthetic event with
|
||||
// no detail is indistinguishable from a real one to it.
|
||||
static navigateReplace(url) {
|
||||
window.history.replaceState(null, '', '#' + url);
|
||||
window.dispatchEvent(new Event('hashchange'));
|
||||
window.history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
// Real "back": retraces the hash the user was actually on before, via native browser history,
|
||||
// Real "back": retraces the route the user was actually on before, via native browser history,
|
||||
// rather than a page's own guess at where it must have been reached from. Returns whether it
|
||||
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
|
||||
// fallback (a page's `back`/`backUid`/`backTopic` props) for the one case this can't cover: a
|
||||
// fresh load, deep link, or refresh, where there's no in-app history yet to go back to (this
|
||||
// counter is in-memory by design and does not survive those) -- callers keep using that fallback
|
||||
// unchanged, just gated behind `if (!HashNavigation.goBack()) { ...existing fallback... }`.
|
||||
// unchanged, just gated behind `if (!Navigation.goBack()) { ...existing fallback... }`.
|
||||
static goBack() {
|
||||
if (navigationCount > 0) {
|
||||
navigationCount--;
|
||||
@@ -72,8 +95,20 @@ export default class HashNavigation {
|
||||
return false;
|
||||
}
|
||||
|
||||
static composeUrlHash(path, params) {
|
||||
let url = path.join('/');
|
||||
static composeUrl(path, params) {
|
||||
let segments = path;
|
||||
// !segments[0] (not === '') deliberately also catches a sparse hole at index 0 -- parseUrl('')
|
||||
// returns path: [] (empty, not ['']; inherited as-is from the original hash-based parseUrlHash,
|
||||
// which had the identical `if (parts[0])` guard), and setUrlTopic('', topic) then does
|
||||
// `parsed.path[1] = topic` on that empty array, leaving index 0 an actual hole rather than ''.
|
||||
// Array#join treats a hole the same as an empty string either way, so this was always silently
|
||||
// producing the same "no view, just a topic" shape -- harmless under the old scheme since a
|
||||
// leading '#//' is still just inert fragment text, but a real path starting '//' is parsed by
|
||||
// pushState/URL as protocol-relative (steals the topic id as the new host) and throws.
|
||||
if (segments.length > 1 && !segments[0]) {
|
||||
segments = [EMPTY_VIEW_PLACEHOLDER, ...segments.slice(1)];
|
||||
}
|
||||
let url = '/' + segments.join('/');
|
||||
const args = [];
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
@@ -88,50 +123,50 @@ export default class HashNavigation {
|
||||
return url;
|
||||
}
|
||||
|
||||
static addUrlParam(hash, key, value) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static addUrlParam(url, key, value) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.params[key] = value;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParam(hash, key) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static removeUrlParam(url, key) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
delete parsed.params[key];
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static removeUrlParams(hash, keys) {
|
||||
static removeUrlParams(url, keys) {
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
for (const key of keys) {
|
||||
delete parsed.params[key];
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlSidePanel(hash, sidepanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlSidePanel(url, sidepanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[0] = sidepanel;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlInfoPanel(hash, infopanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlInfoPanel(url, infopanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
if (infopanel) {
|
||||
parsed.params.info = infopanel;
|
||||
} else {
|
||||
delete parsed.params.info;
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
|
||||
static setUrlTopic(hash, topic) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlTopic(url, topic) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[1] = topic;
|
||||
// Close InfoView on topic change.
|
||||
delete parsed.params.info;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { BASE_APP_NAME } from '../config.js';
|
||||
import {
|
||||
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
|
||||
@@ -16,8 +16,8 @@ const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
// params is optional and additive only -- every existing call site that passes just `view` keeps
|
||||
// producing the exact same bare hash as the old `HashNavigation.navigateTo(view)` did.
|
||||
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([view], params || {}));
|
||||
// producing the exact same bare hash as the old `Navigation.navigateTo(view)` did.
|
||||
const navigate = (view, params) => Navigation.navigateTo(Navigation.composeUrl([view], params || {}));
|
||||
|
||||
function Icon({children, className = ''}) {
|
||||
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
|
||||
@@ -32,7 +32,7 @@ function AppMark({tenantName}) {
|
||||
|
||||
function Header({title, back = 'tenant'}) {
|
||||
return <header className="auth-header">
|
||||
<button type="button" className="auth-icon-button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回">
|
||||
<button type="button" className="auth-icon-button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回">
|
||||
<Icon>arrow_back_ios_new</Icon>
|
||||
</button>
|
||||
<h1>{title}</h1>
|
||||
@@ -247,8 +247,9 @@ function EnterpriseRegister() {
|
||||
<FormAlert text={errors.form} />
|
||||
<label className="auth-label">统一社会信用代码</label>
|
||||
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
|
||||
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode}
|
||||
onIconClick={() => setToast('扫码识别暂不可用,请手动输入')} />
|
||||
placeholder="请输入18位统一社会信用代码" maxLength="18" error={errors.creditCode}
|
||||
action={<button type="button" className="auth-inline-icon"
|
||||
onClick={() => setToast('扫码识别暂不可用,请手动输入')}><Icon>qr_code_scanner</Icon></button>} />
|
||||
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
|
||||
|
||||
<label className="auth-label">上传营业执照 <span>选填</span></label>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
const navigate = view => Navigation.navigateTo(view);
|
||||
|
||||
export const CALL_EXPERIENCE_VIEWS = [
|
||||
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import QRCode from 'qrcodejs';
|
||||
import { pinyin } from 'pinyin-pro';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import {
|
||||
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
|
||||
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
|
||||
@@ -14,9 +14,9 @@ import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cache
|
||||
// Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation --
|
||||
// e.g. address-book -> friend-profile -> friend-settings all need to know which person this is.
|
||||
const navigate = (view, uid) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid');
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
hash = uid ? Navigation.addUrlParam(hash, 'uid', uid) : Navigation.removeUrlParam(hash, 'uid');
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
export const CONTACT_MANAGEMENT_VIEWS = [
|
||||
@@ -44,7 +44,7 @@ function alphaInitial(name) {
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
function Header({title, back = 'messages-home', backUid, right, onRight}) {
|
||||
return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
|
||||
return <header className="people-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
|
||||
}
|
||||
|
||||
// `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch --
|
||||
@@ -589,8 +589,8 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
||||
};
|
||||
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
|
||||
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
|
||||
<section><button type="button" onClick={() => HashNavigation.navigateTo(
|
||||
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
|
||||
<section><button type="button" onClick={() => Navigation.navigateTo(
|
||||
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'history-search'), 'topic', uid))}>
|
||||
<span>查看聊天记录</span><Icon>chevron_right</Icon>
|
||||
</button>
|
||||
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
|
||||
@@ -622,11 +622,11 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
|
||||
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
|
||||
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
|
||||
// target_id/target_name, so build the hash directly here rather than through it.
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), 'abuse-report-reasons');
|
||||
hash = Navigation.addUrlParam(hash, 'target_type', 'user');
|
||||
hash = Navigation.addUrlParam(hash, 'target_id', uid);
|
||||
hash = Navigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
|
||||
Navigation.navigateTo(hash);
|
||||
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
|
||||
</section>
|
||||
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Drafty, Tinode } from 'tinode-sdk';
|
||||
|
||||
import AudioPlayer from '../widgets/audio-player.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
|
||||
import {
|
||||
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
|
||||
@@ -40,15 +40,15 @@ function resolveSourceName(topic, onDone) {
|
||||
}
|
||||
|
||||
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
|
||||
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
|
||||
// `Navigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
|
||||
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
|
||||
// navigation even if a caller threaded one in from outside.
|
||||
const navigate = (view, params) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
|
||||
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
|
||||
});
|
||||
HashNavigation.navigateTo(hash);
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// Same as navigate() above but for switching this page's own type tabs (Favorites' 全部/图片/.../链接,
|
||||
@@ -56,7 +56,7 @@ const navigate = (view, params) => {
|
||||
// which page the user is on, so they shouldn't push a browser-history entry the way a real navigate()
|
||||
// does. Previously every tab click called navigate(), so N tab switches pushed N history entries and
|
||||
// the system back button just cycled through past tabs instead of leaving the page.
|
||||
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
|
||||
|
||||
export const CONTENT_LIBRARY_VIEWS = [
|
||||
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
|
||||
@@ -68,7 +68,7 @@ export const CONTENT_LIBRARY_VIEWS = [
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
function Header({title, back = 'messages-home', backParams, right, onRight}) {
|
||||
return <header className="library-header">
|
||||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<h1>{title}</h1>
|
||||
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
|
||||
</header>;
|
||||
@@ -695,11 +695,11 @@ function History({view, hashParams, myUserId, onToast}) {
|
||||
}).catch(() => {});
|
||||
});
|
||||
}, [items]);
|
||||
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
|
||||
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (Navigation.setUrlTopic('', topicName)
|
||||
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
|
||||
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
|
||||
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
|
||||
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
|
||||
const jump = seq => Navigation.navigateTo(Navigation.addUrlParam(Navigation.setUrlTopic('', topic), 'jumpseq', seq));
|
||||
const nameOf = from => {
|
||||
if (!from) return '';
|
||||
if (from === myUserId || ('usr' + from) === myUserId) return '我';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
const navigate = view => Navigation.navigateTo(view);
|
||||
|
||||
const VIEW_CONFIG = {
|
||||
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import QRCode from 'qrcodejs';
|
||||
import { pinyin } from 'pinyin-pro';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import {
|
||||
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
|
||||
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
|
||||
@@ -17,9 +17,9 @@ import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-c
|
||||
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
|
||||
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
|
||||
const navigate = (view, topic) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
|
||||
@@ -45,8 +45,8 @@ const navigateToProfile = (topic, userId, member) => {
|
||||
...(member.gender ? {gender: member.gender} : {})
|
||||
});
|
||||
}
|
||||
HashNavigation.navigateTo(
|
||||
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
|
||||
Navigation.navigateTo(
|
||||
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'group-member-profile'), 'uid', userId));
|
||||
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
|
||||
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
|
||||
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
|
||||
@@ -140,7 +140,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
|
||||
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
|
||||
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
|
||||
return <header className="group-header">
|
||||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<button type="button" onClick={() => { if (!Navigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<h1>{title}</h1>
|
||||
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
|
||||
</header>;
|
||||
@@ -266,7 +266,7 @@ function JoinRequests({topic, fromChat, onToast}) {
|
||||
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
|
||||
// "group-settings" (closing the open conversation) instead of back to the chat itself.
|
||||
const backToChat = React.useCallback(() => {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
|
||||
}, [topic]);
|
||||
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
|
||||
const [profiles, setProfiles] = React.useState({});
|
||||
@@ -337,14 +337,14 @@ function Announcement({topic, myRole, fromChat, onToast}) {
|
||||
const canEdit = myRole === 'owner' || myRole === 'admin';
|
||||
// Coming from the announcement banner atop the chat itself (messages-view.jsx's
|
||||
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
|
||||
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
|
||||
// path[0]=group-announcement-detail URL -- `fromChat` is the only thing telling them apart, so
|
||||
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
|
||||
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
|
||||
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼路径,所以 path[1] 是空的),返回应该
|
||||
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
|
||||
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
|
||||
// path[1] 依旧为空,tinode-web 的 handleRouteChange 就把 topicSelected 清成 null,会话跟着一起关了。
|
||||
// 这里显式把 topic 写回 path[1]。
|
||||
const backToChat = React.useCallback(() => {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
|
||||
}, [topic]);
|
||||
React.useEffect(() => {
|
||||
if (!topic) return;
|
||||
@@ -491,13 +491,13 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
|
||||
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
|
||||
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
|
||||
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
|
||||
只会写 topic,所以直接拼 hash。 */}
|
||||
只会写 topic,所以直接拼路径。 */}
|
||||
<button type="button" onClick={() => {
|
||||
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
|
||||
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel('', 'abuse-report-reasons');
|
||||
hash = Navigation.addUrlParam(hash, 'target_type', 'group');
|
||||
hash = Navigation.addUrlParam(hash, 'target_id', topic);
|
||||
hash = Navigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
|
||||
Navigation.navigateTo(hash);
|
||||
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
|
||||
</section>
|
||||
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import CheckBox from '../widgets/checkbox.jsx';
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
import ErrorPanel from '../widgets/error-panel.jsx';
|
||||
import GroupManager from '../widgets/group-manager.jsx';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import MenuCancel from '../widgets/menu-cancel.jsx';
|
||||
import PermissionsEditor from '../widgets/permissions-editor.jsx';
|
||||
import ShowQRCode from '../widgets/show-qrcode.jsx';
|
||||
@@ -455,7 +455,7 @@ class InfoView extends React.Component {
|
||||
// system back button had to be pressed once per level ever visited, and each press could land back
|
||||
// inside a sub-panel the user had already explicitly backed out of.
|
||||
handleBackNavigate() {
|
||||
if (HashNavigation.goBack()) {
|
||||
if (Navigation.goBack()) {
|
||||
return;
|
||||
}
|
||||
const args = (this.props.panel || 'info').split('/');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Tinode } from 'tinode-sdk';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { BASE_APP_NAME } from '../config.js';
|
||||
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
|
||||
import { makeImageUrl } from '../lib/blob-helpers.js';
|
||||
@@ -15,9 +15,9 @@ import { topicDisplayName } from '../lib/utils.js';
|
||||
// group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面
|
||||
// 残留的 topic 参数带进不相关的页面)。
|
||||
const navigate = (view, topic) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
|
||||
@@ -27,7 +27,7 @@ const navigate = (view, topic) => {
|
||||
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
|
||||
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
|
||||
// tab switch to back out.
|
||||
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
|
||||
|
||||
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
|
||||
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
|
||||
@@ -501,7 +501,7 @@ function SystemMessages({authenticated}) {
|
||||
};
|
||||
|
||||
return <div className="system-messages-page">
|
||||
<header className="messages-sub-header"><button onClick={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
|
||||
<header className="messages-sub-header"><button onClick={() => { if (!Navigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
|
||||
<main className="system-notification-list">
|
||||
{loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
|
||||
{error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
|
||||
const navigate = view => HashNavigation.navigateTo(view);
|
||||
const navigate = view => Navigation.navigateTo(view);
|
||||
|
||||
export const MESSAGE_COMPOSER_VIEWS = [
|
||||
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
|
||||
|
||||
+15
-15
@@ -49,7 +49,7 @@ import { topicDisplayName } from '../lib/utils.js';
|
||||
import { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
|
||||
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
|
||||
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
|
||||
|
||||
// Run timer with this frequency (ms) for checking notification queue.
|
||||
@@ -725,7 +725,7 @@ class MessagesView extends React.Component {
|
||||
.then(ctrl => {
|
||||
if (ctrl.code == 303) {
|
||||
// Redirect to another topic requested.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', ctrl.params.topic));
|
||||
return;
|
||||
}
|
||||
if (this.state.topic != ctrl.topic) {
|
||||
@@ -1441,28 +1441,28 @@ class MessagesView extends React.Component {
|
||||
}).catch(_ => {});
|
||||
}
|
||||
|
||||
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
|
||||
// Note: setUrlSidePanel only overwrites path[0] -- called with Navigation.currentUrl() while a topic is
|
||||
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
|
||||
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
|
||||
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
|
||||
// whole view blank. Build the target hash from an empty path instead of the current one.
|
||||
// "/group-announcement-detail/grpXXX?topic=grpXXX". handleRouteChange then re-parses path[1] as a topic
|
||||
// selector on every route change, fighting with the sidepanel that's trying to open and leaving the
|
||||
// whole view blank. Build the target URL from an empty path instead of the current one.
|
||||
handleShowAnnouncement(e) {
|
||||
e.preventDefault();
|
||||
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
|
||||
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
|
||||
// without it the "back" button can't know whether to return to group-settings or to this chat.
|
||||
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
|
||||
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
|
||||
hash = Navigation.addUrlParam(hash, 'from', 'chat');
|
||||
Navigation.navigateTo(hash);
|
||||
}
|
||||
|
||||
handleShowJoinRequests(e) {
|
||||
e.preventDefault();
|
||||
// `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
|
||||
// return to this chat instead of always landing on group-governance.
|
||||
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
|
||||
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
|
||||
hash = Navigation.addUrlParam(hash, 'from', 'chat');
|
||||
Navigation.navigateTo(hash);
|
||||
}
|
||||
|
||||
handleSendFriendVerification(e) {
|
||||
@@ -1471,8 +1471,8 @@ class MessagesView extends React.Component {
|
||||
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
|
||||
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
|
||||
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
|
||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(
|
||||
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
|
||||
Navigation.navigateTo(Navigation.addUrlParam(
|
||||
Navigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
|
||||
}
|
||||
|
||||
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
|
||||
@@ -2239,7 +2239,7 @@ class MessagesView extends React.Component {
|
||||
if (!topic || !seq || isNaN(seq)) {
|
||||
return;
|
||||
}
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
|
||||
Navigation.navigateTo(Navigation.removeUrlParam(Navigation.currentUrl(), 'jumpseq'));
|
||||
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
|
||||
if (topic.findMessage(seq)) {
|
||||
jump();
|
||||
|
||||
@@ -9,7 +9,7 @@ import NewTopicById from '../widgets/new-topic-by-id.jsx';
|
||||
import NewTopicGroup from '../widgets/new-topic-group.jsx';
|
||||
import SearchContacts from '../widgets/search-contacts.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { asEmail, asPhone, theCard } from '../lib/utils.js';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -34,7 +34,7 @@ class NewTopicView extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const urlParams = HashNavigation.parseUrlHash(window.location.hash).params;
|
||||
const urlParams = Navigation.parseUrl(Navigation.currentUrl()).params;
|
||||
const initialTab = urlParams['tab'] || 'find';
|
||||
const initialQuery = initialTab == 'find' ? (urlParams['q'] || null) : null;
|
||||
|
||||
@@ -60,7 +60,7 @@ class NewTopicView extends React.Component {
|
||||
// page -- navigateTo() pushed a history entry per click purely to keep the `tab` url param in
|
||||
// sync, so repeatedly switching tabs made the back button cycle through past tabs instead of
|
||||
// leaving this page. navigateReplace() keeps the param in sync without that side effect.
|
||||
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
|
||||
this.setState({tabSelected: e.currentTarget.dataset.id});
|
||||
}
|
||||
|
||||
@@ -91,19 +91,19 @@ class NewTopicView extends React.Component {
|
||||
|
||||
handleSearchResultSelected(topicName) {
|
||||
if (this.state.tabSelected == 'find') {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
}
|
||||
|
||||
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(undefined,
|
||||
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
|
||||
}
|
||||
|
||||
handleGroupByID(topicName) {
|
||||
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import QRCode from 'qrcodejs';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
import { BASE_APP_NAME } from '../config.js';
|
||||
import { PACKAGE_VERSION } from '../version.js';
|
||||
@@ -12,11 +12,22 @@ import {
|
||||
} from '../lib/im-api.js';
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
|
||||
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
|
||||
});
|
||||
HashNavigation.navigateTo(hash);
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// Same as navigate() but replaces the current history entry instead of pushing a new one -- for
|
||||
// steps within a single flow (e.g. youth-mode off/on/verify) so "back" leaves the flow instead of
|
||||
// re-walking its already-completed steps.
|
||||
const navigateReplace = (view, params) => {
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
|
||||
});
|
||||
Navigation.navigateReplace(hash);
|
||||
};
|
||||
|
||||
export const PERSONAL_SETTINGS_VIEWS = [
|
||||
@@ -33,7 +44,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
|
||||
|
||||
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
|
||||
return <header className="personal-header">
|
||||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<h1>{title}</h1>
|
||||
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
|
||||
</header>;
|
||||
@@ -319,8 +330,8 @@ function SettingsList({onToast, onLogout, tinode, chatList, onDeleteAccount}) {
|
||||
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
|
||||
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
|
||||
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
|
||||
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
|
||||
用的是同一套 hash 导航,可以直接指过去。 */}
|
||||
地址栏才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
|
||||
用的是同一套导航机制,可以直接指过去。 */}
|
||||
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
|
||||
</section>
|
||||
<section><MenuRow icon="no_accounts" title="注销账号" onClick={() => setSheet('delete')} danger /></section>
|
||||
@@ -460,7 +471,7 @@ function ChangePassword({onUpdatePassword, onToast}) {
|
||||
});
|
||||
};
|
||||
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成"
|
||||
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main>
|
||||
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main className="change-password-fields">
|
||||
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
|
||||
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
|
||||
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
|
||||
@@ -648,7 +659,7 @@ function YouthMode({onToast}) {
|
||||
window.setTimeout(() => navigate('identity-check', {ym: 'enable'}), 400);
|
||||
return;
|
||||
}
|
||||
navigate('youth-mode-verify', {ym: 'enable'});
|
||||
navigateReplace('youth-mode-verify', {ym: 'enable'});
|
||||
};
|
||||
|
||||
if (!settings) return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /></div>;
|
||||
@@ -663,7 +674,7 @@ function YouthMode({onToast}) {
|
||||
</main>
|
||||
<footer>
|
||||
<button type="button" className={settings.enabled ? 'outline' : ''}
|
||||
onClick={() => settings.enabled ? navigate('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
|
||||
onClick={() => settings.enabled ? navigateReplace('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
|
||||
{settings.enabled ? '关闭青少年模式' : '开启青少年模式'}
|
||||
</button>
|
||||
<small>可在设置中查看青少年模式状态</small>
|
||||
@@ -718,7 +729,7 @@ function YouthModeVerify({hashParams, onToast}) {
|
||||
// flip, which is a more confusing flash than the plain loading state they had before caching.
|
||||
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
|
||||
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
|
||||
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
|
||||
window.setTimeout(() => navigateReplace(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
|
||||
}).catch(err => {
|
||||
// Server locks out further attempts for retry_after_seconds after too many wrong passwords
|
||||
// (any request during that window 429s regardless of whether THIS password is correct) --
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
|
||||
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
|
||||
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
|
||||
});
|
||||
HashNavigation.navigateTo(hash);
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
export const SECURITY_SUPPORT_VIEWS = [
|
||||
@@ -20,7 +20,7 @@ export const SECURITY_SUPPORT_VIEWS = [
|
||||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||||
|
||||
function Header({title, back}) {
|
||||
return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
|
||||
return <header className="support-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
|
||||
}
|
||||
|
||||
function Toast({message, onDone}) {
|
||||
@@ -246,7 +246,7 @@ function UserFeedback({tinode, onToast}) {
|
||||
<small>{content.length}/{maxLength}</small>
|
||||
</label>
|
||||
<section className="feedback-upload">
|
||||
<header><span><strong>图片</strong></span><em>{images.length}/{maxFiles}</em></header>
|
||||
<header><strong>图片({images.length}/{maxFiles})</strong></header>
|
||||
<button type="button" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_a_photo</Icon><span>{uploading ? '上传中…' : '点击上传'}</span></button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addImage} />
|
||||
</section>
|
||||
|
||||
+71
-71
@@ -31,7 +31,7 @@ import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/hos
|
||||
import LocalStorageUtil from '../lib/local-storage.js';
|
||||
import { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js';
|
||||
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { secondsToTime } from '../lib/strformat.js'
|
||||
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
|
||||
|
||||
@@ -101,7 +101,7 @@ const messages = defineMessages({
|
||||
}
|
||||
});
|
||||
|
||||
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
|
||||
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleRouteChange 里,是因为初始化时的
|
||||
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
|
||||
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
|
||||
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
|
||||
@@ -231,7 +231,7 @@ class TinodeWeb extends React.Component {
|
||||
this.state = this.getBlankState();
|
||||
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleHashRoute = this.handleHashRoute.bind(this);
|
||||
this.handleRouteChange = this.handleRouteChange.bind(this);
|
||||
this.handleOnline = this.handleOnline.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
|
||||
@@ -470,7 +470,7 @@ class TinodeWeb extends React.Component {
|
||||
window.addEventListener('online', this.handleOnlineOn);
|
||||
this.handleOnlineOff = _ => { this.handleOnline(false); }
|
||||
window.addEventListener('offline', this.handleOnlineOff);
|
||||
window.addEventListener('hashchange', this.handleHashRoute);
|
||||
window.addEventListener('popstate', this.handleRouteChange);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
|
||||
|
||||
// Window/tab visible or invisible for pausing timers.
|
||||
@@ -550,24 +550,24 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
|
||||
// Parse the hash navigation params.
|
||||
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const parsedNav = Navigation.parseUrl(Navigation.currentUrl());
|
||||
// Maybe navigate to home screen.
|
||||
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
|
||||
// Save possible topic name.
|
||||
this.setState({requestedTopic: parsedNav.path[1]});
|
||||
const path = parsedNav.params && parsedNav.params.cred_done ?
|
||||
HashNavigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
|
||||
Navigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
|
||||
'';
|
||||
HashNavigation.navigateTo(path);
|
||||
Navigation.navigateTo(path);
|
||||
} else {
|
||||
this.handleHashRoute();
|
||||
this.handleRouteChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('hashchange', this.handleHashRoute);
|
||||
window.removeEventListener('popstate', this.handleRouteChange);
|
||||
window.removeEventListener('online', this.handleOnlineOn);
|
||||
window.removeEventListener('offline', this.handleOnlineOff);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
|
||||
@@ -742,7 +742,7 @@ class TinodeWeb extends React.Component {
|
||||
alertVisible: false
|
||||
});
|
||||
// Navigate to contacts or login view
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -766,9 +766,9 @@ class TinodeWeb extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle for hash navigation (hashchange) event: update state.
|
||||
handleHashRoute() {
|
||||
const hash = HashNavigation.parseUrlHash(window.location.hash);
|
||||
// Handler for route navigation (popstate) event: update state.
|
||||
handleRouteChange() {
|
||||
const hash = Navigation.parseUrl(Navigation.currentUrl());
|
||||
// Browser back navigation after a successful login re-renders one of the pre-login views
|
||||
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
|
||||
// hash routing and the WS session's lifecycle are independent, so nothing else here would
|
||||
@@ -780,7 +780,7 @@ class TinodeWeb extends React.Component {
|
||||
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
|
||||
// that code runs removes the only way to reach it.
|
||||
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
return;
|
||||
}
|
||||
// Start with panel parameters.
|
||||
@@ -804,13 +804,13 @@ class TinodeWeb extends React.Component {
|
||||
// A syntactically valid topic name is not enough on its own: mobilePanel='topic-view' only
|
||||
// ever pairs correctly with MessagesView actually being allowed to render, which requires
|
||||
// !chapterOneActive (i.e. this.tinode.isAuthenticated()) -- see tinode-web.jsx's render().
|
||||
// Without this check, opening (or being redirected/deep-linked to) a #/<topic> URL while
|
||||
// Without this check, opening (or being redirected/deep-linked to) a `/<topic>` URL while
|
||||
// logged out set mobilePanel to 'topic-view' anyway; on mobile that made *both* the
|
||||
// SidepanelView gate (`mobilePanel=='sidepanel'`) and the MessagesView gate
|
||||
// (`!chapterOneActive`) false at once, so neither rendered -- a permanently blank white
|
||||
// screen (app-container empty, no thrown error) that a hash change alone couldn't recover
|
||||
// from, only a full reload. Reproduced live: logging out, then setting location.hash to a
|
||||
// real group's `#/<topic>` URL. Falling back to the same "invalid topic" branch already used
|
||||
// screen (app-container empty, no thrown error) that a route change alone couldn't recover
|
||||
// from, only a full reload. Reproduced live: logging out, then navigating to a real group's
|
||||
// `/<topic>` URL. Falling back to the same "invalid topic" branch already used
|
||||
// for a malformed name reuses a path already proven safe with topicName=null.
|
||||
if (!Tinode.topicType(topicName) || !this.tinode.isAuthenticated()) {
|
||||
// Clear invalid topic name and hide messages view on mobile.
|
||||
@@ -1009,7 +1009,7 @@ class TinodeWeb extends React.Component {
|
||||
doLogin(login, password, tmpToken, cred) {
|
||||
if (this.tinode.isAuthenticated()) {
|
||||
// Already logged in. Go to default screen.
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1017,7 +1017,7 @@ class TinodeWeb extends React.Component {
|
||||
if (!(login && password) && !token) {
|
||||
// No login credentials provided.
|
||||
// Make sure we are on the login page.
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
this.setState({loginDisabled: false});
|
||||
return;
|
||||
}
|
||||
@@ -1064,7 +1064,7 @@ class TinodeWeb extends React.Component {
|
||||
if (token) {
|
||||
// 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。
|
||||
this.handleLogout();
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
}
|
||||
// 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''),
|
||||
// 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被
|
||||
@@ -1085,12 +1085,12 @@ class TinodeWeb extends React.Component {
|
||||
if (!Array.isArray(params.cred) || !params.cred.length) {
|
||||
return false;
|
||||
}
|
||||
const parsed = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const parsed = Navigation.parseUrl(Navigation.currentUrl());
|
||||
parsed.path[0] = 'cred';
|
||||
parsed.params['method'] = params.cred[0];
|
||||
parsed.params['token'] = params.token;
|
||||
parsed.params['code'] = params.code;
|
||||
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
|
||||
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1120,7 +1120,7 @@ class TinodeWeb extends React.Component {
|
||||
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
|
||||
// since it navigates away immediately -- until the same Login form could be reached again
|
||||
// afterwards (browser back) with this still stuck true, permanently disabling the submit
|
||||
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
|
||||
// button. handleRouteChange's redirect above now closes off that specific path, but resetting
|
||||
// it here too means this can't get stuck regardless of how the form ends up rendered again.
|
||||
loginDisabled: false,
|
||||
credMethod: undefined,
|
||||
@@ -1142,7 +1142,7 @@ class TinodeWeb extends React.Component {
|
||||
this.tinode.disconnect();
|
||||
localStorage.removeItem('auth-token');
|
||||
this.handleError(err.message, 'err');
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
}).finally(_ => {
|
||||
this.setState({loadSpinnerVisible: false});
|
||||
});
|
||||
@@ -1151,17 +1151,17 @@ class TinodeWeb extends React.Component {
|
||||
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
|
||||
//
|
||||
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
|
||||
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
|
||||
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
|
||||
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
|
||||
// 改写路由,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
|
||||
// 被弹到工作台。现在先看当前路由是不是一个有效的、非登录流程的视图,是就原样保留。
|
||||
const currentView = Navigation.parseUrl(Navigation.currentUrl()).path[0];
|
||||
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
|
||||
!SIDE_PANEL_VIEWS.includes(currentView);
|
||||
let urlHash = cameFromAuthFlow ?
|
||||
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
|
||||
Navigation.setUrlSidePanel(Navigation.currentUrl(), 'workbench') : Navigation.currentUrl();
|
||||
if (goToTopic) {
|
||||
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
|
||||
urlHash = Navigation.setUrlTopic(urlHash, goToTopic);
|
||||
}
|
||||
HashNavigation.navigateTo(urlHash);
|
||||
Navigation.navigateTo(urlHash);
|
||||
}
|
||||
|
||||
tnMeMetaDesc(desc) {
|
||||
@@ -1425,7 +1425,7 @@ class TinodeWeb extends React.Component {
|
||||
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
|
||||
forwardMessage: null
|
||||
});
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', topicName));
|
||||
}
|
||||
} else {
|
||||
// Currently selected contact deleted
|
||||
@@ -1440,7 +1440,7 @@ class TinodeWeb extends React.Component {
|
||||
forwardMessage: null
|
||||
});
|
||||
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1457,8 +1457,8 @@ class TinodeWeb extends React.Component {
|
||||
this.setState({
|
||||
mobilePanel: 'sidepanel'
|
||||
});
|
||||
if (!HashNavigation.goBack()) {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
|
||||
if (!Navigation.goBack()) {
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1563,7 +1563,7 @@ class TinodeWeb extends React.Component {
|
||||
handleNewAccount() {
|
||||
this.handleError();
|
||||
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register'));
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'register'));
|
||||
}
|
||||
|
||||
// Actual registration of a new account.
|
||||
@@ -1666,7 +1666,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
handleSelectWallpapers() {
|
||||
this.handleError();
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'wallpapers'));
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'wallpapers'));
|
||||
}
|
||||
|
||||
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
|
||||
@@ -1707,7 +1707,7 @@ class TinodeWeb extends React.Component {
|
||||
handleSettings() {
|
||||
this.handleError();
|
||||
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
|
||||
this.state.myUserId ? 'edit' : 'settings'));
|
||||
}
|
||||
|
||||
@@ -1741,7 +1741,7 @@ class TinodeWeb extends React.Component {
|
||||
secureConnection: secureConnection,
|
||||
});
|
||||
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), ''));
|
||||
}
|
||||
|
||||
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
|
||||
@@ -1776,13 +1776,13 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
// User chose 'Archived chats'.
|
||||
handleShowArchive() {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
|
||||
this.state.myUserId ? 'archive' : ''));
|
||||
}
|
||||
|
||||
// User viewes 'Blocked chats'.
|
||||
handleShowBlocked() {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
|
||||
this.state.myUserId ? 'blocked' : ''));
|
||||
}
|
||||
|
||||
@@ -1838,7 +1838,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
// User clicked Cancel button in Setting or Sign Up panel.
|
||||
handleSidepanelCancel() {
|
||||
const parsed = HashNavigation.parseUrlHash(window.location.hash);
|
||||
const parsed = Navigation.parseUrl(Navigation.currentUrl());
|
||||
let path = '';
|
||||
if (['security','support','general','notif','acc_general'].includes(parsed.path[0])) {
|
||||
path = 'edit';
|
||||
@@ -1859,18 +1859,18 @@ class TinodeWeb extends React.Component {
|
||||
delete parsed.params.scheme;
|
||||
delete parsed.params.token;
|
||||
}
|
||||
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
|
||||
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
|
||||
this.setState({errorText: '', errorLevel: null});
|
||||
}
|
||||
|
||||
// Sidepanel navigator. No need to bind to 'this'.
|
||||
basicNavigator(hash) {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
|
||||
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), hash));
|
||||
}
|
||||
|
||||
// Topic info navigator. No need to bind to 'this'.
|
||||
infoNavigator(hash) {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
|
||||
Navigation.navigateTo(Navigation.setUrlInfoPanel(Navigation.currentUrl(), hash));
|
||||
}
|
||||
|
||||
// Request to start a topic, new or selected from search results, or "by ID".
|
||||
@@ -1911,7 +1911,7 @@ class TinodeWeb extends React.Component {
|
||||
nextState.topicSelected = newName;
|
||||
}
|
||||
this.setState(nextState, _ => {
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', newName));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2033,7 +2033,7 @@ class TinodeWeb extends React.Component {
|
||||
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
|
||||
this.tinode.onInfoMessage = this.handleInfoMessage;
|
||||
this.tinode.onDataMessage = this.handleDataMessage;
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -2053,7 +2053,7 @@ class TinodeWeb extends React.Component {
|
||||
// Request to hard-delete topic.
|
||||
topic.delTopic(true).then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -2078,7 +2078,7 @@ class TinodeWeb extends React.Component {
|
||||
|
||||
topic.leave(true).then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -2093,7 +2093,7 @@ class TinodeWeb extends React.Component {
|
||||
topic.updateMode(null, '-JP')
|
||||
.then(_ => {
|
||||
// Hide MessagesView and InfoView panels.
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
|
||||
})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
@@ -2118,10 +2118,10 @@ class TinodeWeb extends React.Component {
|
||||
// Remove J and P permissions (the "block" half of "block and report").
|
||||
topic.updateMode(null, '-JP')
|
||||
.then(_ => {
|
||||
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
|
||||
hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
|
||||
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
|
||||
hash = Navigation.setUrlSidePanel(hash, 'abuse-report-reasons');
|
||||
Object.entries(params).forEach(([key, value]) => { hash = Navigation.addUrlParam(hash, key, value); });
|
||||
Navigation.navigateTo(hash);
|
||||
})
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
@@ -2352,10 +2352,10 @@ class TinodeWeb extends React.Component {
|
||||
...data.user,
|
||||
capabilities: {can_add_friend: !!data.user.can_add}
|
||||
});
|
||||
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
|
||||
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
|
||||
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
|
||||
hash = Navigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
|
||||
hash = Navigation.addUrlParam(hash, 'uid', data.user.user_id);
|
||||
Navigation.navigateTo(hash);
|
||||
return;
|
||||
}
|
||||
this.setState({scannerOpen: false});
|
||||
@@ -2400,7 +2400,7 @@ class TinodeWeb extends React.Component {
|
||||
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
|
||||
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
|
||||
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
|
||||
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
|
||||
// Ch.5/6, with real Navigation.goBack()-based back navigation) had no way to actually be reached
|
||||
// from an open chat's own header -- every test of them this engagement typed the hash directly.
|
||||
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
|
||||
// parent per panel and never used real browser history, so routing here fixed both problems at once
|
||||
@@ -2408,25 +2408,25 @@ class TinodeWeb extends React.Component {
|
||||
handleShowInfoView() {
|
||||
const topicName = this.state.topicSelected;
|
||||
if (Tinode.isP2PTopicName(topicName)) {
|
||||
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
|
||||
// Base the new URL on '' (not Navigation.currentUrl()) -- the current URL's path[1] still holds
|
||||
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
|
||||
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
|
||||
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
|
||||
// URL as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
|
||||
// friend-settings' own query params, and handleRouteChange re-interprets the leftover path[1] as a
|
||||
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
|
||||
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
|
||||
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
|
||||
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel('', 'friend-settings');
|
||||
hash = Navigation.addUrlParam(hash, 'uid', topicName);
|
||||
Navigation.navigateTo(hash);
|
||||
return;
|
||||
}
|
||||
if (!Tinode.isSelfTopicName(topicName)) {
|
||||
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
|
||||
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
|
||||
HashNavigation.navigateTo(hash);
|
||||
let hash = Navigation.setUrlSidePanel('', 'group-settings');
|
||||
hash = Navigation.addUrlParam(hash, 'topic', topicName);
|
||||
Navigation.navigateTo(hash);
|
||||
return;
|
||||
}
|
||||
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
|
||||
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
|
||||
Navigation.navigateTo(Navigation.addUrlParam(Navigation.currentUrl(), 'info', 'info'));
|
||||
this.setState({infoPanel: 'info'});
|
||||
}
|
||||
|
||||
@@ -2459,7 +2459,7 @@ class TinodeWeb extends React.Component {
|
||||
if (this.tinode.isAuthenticated()) {
|
||||
// Adding new email or phone number in account setting.
|
||||
this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}})
|
||||
.then(_ => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'contacts')))
|
||||
.then(_ => Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'contacts')))
|
||||
.catch(err => this.handleError(err.message, 'err'));
|
||||
} else {
|
||||
// Credential validation on signup.
|
||||
@@ -2487,7 +2487,7 @@ class TinodeWeb extends React.Component {
|
||||
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret}))
|
||||
.then(_ => {
|
||||
this.handleError(this.props.intl.formatMessage(messages.password_reset_success), 'info');
|
||||
HashNavigation.navigateTo('');
|
||||
Navigation.navigateTo('');
|
||||
})
|
||||
.catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Edit account parameters.
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import { WALLPAPER_DEFAULTS } from '../config.js';
|
||||
import { wallpaperNameFromUrl, wallpaperTypeFromUrl } from '../lib/utils.js';
|
||||
import imageIndex from '../../img/bkg/index.json';
|
||||
@@ -31,7 +31,7 @@ export default class WallpapersView extends React.PureComponent {
|
||||
e.preventDefault();
|
||||
// Same-page tab switch (pattern / image), not a navigation to a different page -- see the same
|
||||
// fix/comment in new-topic-view.jsx's handleTabClick.
|
||||
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
|
||||
this.setState({tab: e.currentTarget.dataset.id});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import ContactList from '../widgets/contact-list.jsx';
|
||||
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
import {
|
||||
uploadLargeFile, getMe, getFileUrl, getUserProfile,
|
||||
getWorkbench,
|
||||
@@ -15,20 +15,20 @@ import {
|
||||
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
|
||||
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
|
||||
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
|
||||
});
|
||||
HashNavigation.navigateTo(hash);
|
||||
Navigation.navigateTo(hash);
|
||||
};
|
||||
|
||||
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
|
||||
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
|
||||
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
|
||||
// switching back and forth a few times made the system back button (and this app's own Header back
|
||||
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
|
||||
// button, gated on Navigation.goBack()) cycle through past toggle states instead of leaving the
|
||||
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
|
||||
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
|
||||
|
||||
export const WORKPLACE_COLLABORATION_VIEWS = [
|
||||
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
|
||||
@@ -41,7 +41,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
|
||||
|
||||
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
|
||||
return <header className="workplace-header">
|
||||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||||
<h1>{title}</h1>
|
||||
{action ? <button type="button" onClick={onAction} aria-label={actionLabel || '操作'} disabled={actionDisabled}><Icon>{action}</Icon></button> : <span />}
|
||||
</header>;
|
||||
|
||||
@@ -11,7 +11,7 @@ import ReceivedMarker from './received-marker.jsx'
|
||||
|
||||
import { fullFormatter } from '../lib/formatters.js';
|
||||
import { sanitizeUrl } from '../lib/utils.js';
|
||||
import HashNavigation from '../lib/navigation.js';
|
||||
import Navigation from '../lib/navigation.js';
|
||||
|
||||
// 4.9 语音未读红点:纯客户端概念,没有对应的后端字段——原生 Tinode 的 recv/read 只标记到
|
||||
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
|
||||
@@ -62,6 +62,10 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleTouchStart = this.handleTouchStart.bind(this);
|
||||
this.handleTouchMove = this.handleTouchMove.bind(this);
|
||||
this.handleTouchEnd = this.handleTouchEnd.bind(this);
|
||||
this.suppressNativeContextMenu = this.suppressNativeContextMenu.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleDraftyClick = this.handleDraftyClick.bind(this);
|
||||
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
|
||||
@@ -100,17 +104,17 @@ class BaseChatMessage extends React.PureComponent {
|
||||
// tinode:topic/usr123abc -> usr123abc
|
||||
const pathname = new URL(e.target.dataset.val)?.pathname;
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', parts.pop() || ''));
|
||||
Navigation.navigateTo(Navigation.setUrlTopic('', parts.pop() || ''));
|
||||
} catch (error) {
|
||||
console.error("Invalid URL:", error);
|
||||
}
|
||||
break;
|
||||
case 'contact_find':
|
||||
e.preventDefault();
|
||||
let hashUrl = HashNavigation.setUrlSidePanel(window.location.hash, 'newtpk');
|
||||
hashUrl = HashNavigation.addUrlParam(hashUrl, 'q', e.target.dataset.val);
|
||||
hashUrl = HashNavigation.addUrlParam(hashUrl, 'tab', 'find');
|
||||
HashNavigation.navigateTo(hashUrl);
|
||||
let hashUrl = Navigation.setUrlSidePanel(Navigation.currentUrl(), 'newtpk');
|
||||
hashUrl = Navigation.addUrlParam(hashUrl, 'q', e.target.dataset.val);
|
||||
hashUrl = Navigation.addUrlParam(hashUrl, 'tab', 'find');
|
||||
Navigation.navigateTo(hashUrl);
|
||||
break;
|
||||
default:
|
||||
// No special handling; let the browser deal with it.
|
||||
@@ -163,9 +167,67 @@ class BaseChatMessage extends React.PureComponent {
|
||||
this.props.onFormResponse(e.target.dataset.act, text, data);
|
||||
}
|
||||
|
||||
// docs/images/消息页面—长按弹出.png names this "long-press", but the only wiring that ever existed
|
||||
// was handleContextClick below, bound to a click on the tiny .menuTrigger chevron -- no touchstart/
|
||||
// setTimeout path, checked across this file's entire history. Real touch devices have no way to
|
||||
// trigger it that matches the reference image. This adds an actual press-and-hold gesture on the
|
||||
// bubble itself, reusing the same menu-building logic so both paths stay in sync.
|
||||
handleTouchStart(e) {
|
||||
if (!this.props.showContextMenu || this.props.selectMode) {
|
||||
return;
|
||||
}
|
||||
const touch = e.touches && e.touches[0];
|
||||
this.longPressOrigin = {x: touch ? touch.pageX : e.pageX, y: touch ? touch.pageY : e.pageY};
|
||||
this.longPressTimer = window.setTimeout(() => {
|
||||
this.longPressTimer = null;
|
||||
this.openContextMenu(this.longPressOrigin.x, this.longPressOrigin.y);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
handleTouchMove(e) {
|
||||
if (!this.longPressOrigin) {
|
||||
return;
|
||||
}
|
||||
const touch = e.touches && e.touches[0];
|
||||
const x = touch ? touch.pageX : e.pageX;
|
||||
const y = touch ? touch.pageY : e.pageY;
|
||||
// A real press-and-hold shouldn't fire if the finger is actually scrolling the list.
|
||||
if (Math.abs(x - this.longPressOrigin.x) > 10 || Math.abs(y - this.longPressOrigin.y) > 10) {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
}
|
||||
|
||||
handleTouchEnd() {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
|
||||
cancelLongPress() {
|
||||
if (this.longPressTimer) {
|
||||
window.clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.longPressOrigin = null;
|
||||
}
|
||||
|
||||
// The press-and-hold gesture above is our own timer-based approximation of "long press" -- it
|
||||
// doesn't stop the browser's own native context menu (right-click on desktop, or the OS-level
|
||||
// long-press menu some browsers still fire as a real `contextmenu` event even for a touch/mouse
|
||||
// hold). Left alone, both menus show up stacked on top of each other.
|
||||
suppressNativeContextMenu(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
|
||||
handleContextClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.openContextMenu(e.pageX, e.pageY);
|
||||
}
|
||||
|
||||
openContextMenu(x, y) {
|
||||
const menuItems = [];
|
||||
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
|
||||
menuItems.push('menu_item_send_retry');
|
||||
@@ -203,8 +265,8 @@ class BaseChatMessage extends React.PureComponent {
|
||||
head: this.props.head,
|
||||
userFrom: this.props.userFrom,
|
||||
userName: this.props.userName,
|
||||
y: e.pageY,
|
||||
x: e.pageX,
|
||||
y: y,
|
||||
x: x,
|
||||
pickReply: this.props.pickReply,
|
||||
editMessage: this.props.editMessage,
|
||||
timestamp: this.props.timestamp,
|
||||
@@ -418,7 +480,9 @@ class BaseChatMessage extends React.PureComponent {
|
||||
{isFailed && failReason ?
|
||||
<i className="material-icons red message-fail-icon">error</i> : null}
|
||||
<div>
|
||||
<div className={bubbleClass}>
|
||||
<div className={bubbleClass}
|
||||
onTouchStart={this.handleTouchStart} onTouchMove={this.handleTouchMove} onTouchEnd={this.handleTouchEnd}
|
||||
onContextMenu={this.suppressNativeContextMenu}>
|
||||
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
|
||||
<div className="content-meta">
|
||||
<div className={textSizeClass}>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,2 +1,2 @@
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[686],{3686:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),r=t(184),n=t(3272),d=t(7849),h=t(1781),p=t(4848);class c extends l().PureComponent{constructor(e){super(e);const a=(0,d.MC)(this.props.wallpaper),t=(0,d.t7)(this.props.wallpaper);this.state={tab:a||n.S.type,selectedType:a||n.S.type,wallpaper:t||h[n.S.type][n.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),r.A.navigateTo(r.A.addUrlParam(window.location.hash,"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=n.S.type,a=n.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${n.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${n.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=n.S.type||this.state.wallpaper!=h[n.S.type][n.S.index].name||0!=this.state.blur}render(){return(0,p.jsxs)("div",{className:"flex-column",children:[(0,p.jsx)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxs)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsx)("i",{className:"material-icons",children:"undo"})," ",(0,p.jsx)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"})]})}),(0,p.jsxs)("ul",{className:"tabbar",children:[(0,p.jsx)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"})})}),(0,p.jsx)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"})})})]}),(0,p.jsx)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsx)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}})},a)})}):(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)("div",{className:"panel-form-column",children:[(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("label",{className:"small",children:(0,p.jsx)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"})})}),(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged})})]}),(0,p.jsx)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}})},a)})})]})})]})}}}}]);
|
||||
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[686],{3686:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),r=t(184),n=t(3272),d=t(7849),h=t(1781),p=t(4848);class c extends l().PureComponent{constructor(e){super(e);const a=(0,d.MC)(this.props.wallpaper),t=(0,d.t7)(this.props.wallpaper);this.state={tab:a||n.S.type,selectedType:a||n.S.type,wallpaper:t||h[n.S.type][n.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),r.A.navigateReplace(r.A.addUrlParam(r.A.currentUrl(),"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=n.S.type,a=n.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${n.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${n.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=n.S.type||this.state.wallpaper!=h[n.S.type][n.S.index].name||0!=this.state.blur}render(){return(0,p.jsxs)("div",{className:"flex-column",children:[(0,p.jsx)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxs)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsx)("i",{className:"material-icons",children:"undo"})," ",(0,p.jsx)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"})]})}),(0,p.jsxs)("ul",{className:"tabbar",children:[(0,p.jsx)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"})})}),(0,p.jsx)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"})})})]}),(0,p.jsx)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsx)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}})},a)})}):(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)("div",{className:"panel-form-column",children:[(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("label",{className:"small",children:(0,p.jsx)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"})})}),(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged})})]}),(0,p.jsx)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}})},a)})})]})})]})}}}}]);
|
||||
//# sourceMappingURL=686.prod.js.map
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+140
-94
@@ -4557,16 +4557,20 @@ class LocalStorageUtil {
|
||||
"use strict";
|
||||
__webpack_require__.r(__webpack_exports__);
|
||||
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
||||
/* harmony export */ "default": function() { return /* binding */ HashNavigation; }
|
||||
/* harmony export */ "default": function() { return /* binding */ Navigation; }
|
||||
/* harmony export */ });
|
||||
let navigationCount = 0;
|
||||
class HashNavigation {
|
||||
static parseUrlHash(hash) {
|
||||
const parts = hash.split('?', 2);
|
||||
const EMPTY_VIEW_PLACEHOLDER = '_';
|
||||
class Navigation {
|
||||
static parseUrl(url) {
|
||||
const parts = url.split('?', 2);
|
||||
const params = {};
|
||||
let path = [];
|
||||
if (parts[0]) {
|
||||
path = parts[0].replace('#', '').split('/');
|
||||
path = parts[0].replace(/^\//, '').split('/');
|
||||
if (path.length > 1 && path[0] === EMPTY_VIEW_PLACEHOLDER) {
|
||||
path[0] = '';
|
||||
}
|
||||
}
|
||||
if (parts[1]) {
|
||||
parts[1].split('&').forEach(arg => {
|
||||
@@ -4581,13 +4585,17 @@ class HashNavigation {
|
||||
params: params
|
||||
};
|
||||
}
|
||||
static currentUrl() {
|
||||
return window.location.pathname + window.location.search;
|
||||
}
|
||||
static navigateTo(url) {
|
||||
navigationCount++;
|
||||
window.location.hash = url;
|
||||
window.history.pushState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
static navigateReplace(url) {
|
||||
window.history.replaceState(null, '', '#' + url);
|
||||
window.dispatchEvent(new Event('hashchange'));
|
||||
window.history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
static goBack() {
|
||||
if (navigationCount > 0) {
|
||||
@@ -4597,8 +4605,12 @@ class HashNavigation {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static composeUrlHash(path, params) {
|
||||
let url = path.join('/');
|
||||
static composeUrl(path, params) {
|
||||
let segments = path;
|
||||
if (segments.length > 1 && !segments[0]) {
|
||||
segments = [EMPTY_VIEW_PLACEHOLDER, ...segments.slice(1)];
|
||||
}
|
||||
let url = '/' + segments.join('/');
|
||||
const args = [];
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
@@ -4612,45 +4624,45 @@ class HashNavigation {
|
||||
}
|
||||
return url;
|
||||
}
|
||||
static addUrlParam(hash, key, value) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static addUrlParam(url, key, value) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.params[key] = value;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
static removeUrlParam(hash, key) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static removeUrlParam(url, key) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
delete parsed.params[key];
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
static removeUrlParams(hash, keys) {
|
||||
static removeUrlParams(url, keys) {
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
for (const key of keys) {
|
||||
delete parsed.params[key];
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
static setUrlSidePanel(hash, sidepanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlSidePanel(url, sidepanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[0] = sidepanel;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
static setUrlInfoPanel(hash, infopanel) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlInfoPanel(url, infopanel) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
if (infopanel) {
|
||||
parsed.params.info = infopanel;
|
||||
} else {
|
||||
delete parsed.params.info;
|
||||
}
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
static setUrlTopic(hash, topic) {
|
||||
const parsed = HashNavigation.parseUrlHash(hash);
|
||||
static setUrlTopic(url, topic) {
|
||||
const parsed = Navigation.parseUrl(url);
|
||||
parsed.path[1] = topic;
|
||||
delete parsed.params.info;
|
||||
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
|
||||
return Navigation.composeUrl(parsed.path, parsed.params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5604,7 +5616,7 @@ const PHONE_RE = /^\d{11}$/;
|
||||
const INVITE_RE = /^[A-Za-z]{5}$/;
|
||||
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const navigate = (view, params) => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].composeUrlHash([view], params || {}));
|
||||
const navigate = (view, params) => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].composeUrl([view], params || {}));
|
||||
function Icon({
|
||||
children,
|
||||
className = ''
|
||||
@@ -6005,9 +6017,15 @@ function EnterpriseRegister() {
|
||||
onChange: e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, '')),
|
||||
placeholder: "请输入18位统一社会信用代码",
|
||||
maxLength: "18",
|
||||
icon: "qr_code_scanner",
|
||||
error: errors.creditCode,
|
||||
onIconClick: () => setToast('扫码识别暂不可用,请手动输入')
|
||||
action: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
className: "auth-inline-icon",
|
||||
onClick: () => setToast('扫码识别暂不可用,请手动输入'),
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(Icon, {
|
||||
children: "qr_code_scanner"
|
||||
}, void 0, false)
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("p", {
|
||||
className: "auth-help",
|
||||
children: "请填写营业执照上的统一社会信用代码"
|
||||
@@ -7514,7 +7532,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, uid) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
|
||||
hash = uid ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'uid', uid) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, 'uid');
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
|
||||
};
|
||||
@@ -8493,7 +8511,7 @@ function Settings({
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("section", {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
onClick: () => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid)),
|
||||
onClick: () => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'history-search'), 'topic', uid)),
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
|
||||
children: "查看聊天记录"
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
|
||||
@@ -8595,7 +8613,7 @@ function Settings({
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
onClick: () => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'abuse-report-reasons');
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_type', 'user');
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_id', uid);
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_name', profile && profile.display_name || uid);
|
||||
@@ -8981,13 +8999,13 @@ function resolveSourceName(topic, onDone) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
const navigate = (view, params) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, key);
|
||||
});
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
|
||||
};
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view));
|
||||
const CONTENT_LIBRARY_VIEWS = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'];
|
||||
function Icon({
|
||||
children
|
||||
@@ -9444,8 +9462,9 @@ function Favorites({
|
||||
});
|
||||
}, [items]);
|
||||
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
|
||||
const deleteSelected = () => {
|
||||
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
|
||||
const [confirmingDelete, setConfirmingDelete] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
|
||||
const confirmDelete = () => {
|
||||
setConfirmingDelete(false);
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_5__.batchDeleteFavorites)(selected).then(result => {
|
||||
const okIds = new Set((result && result.items || []).filter(r => r.ok).map(r => r.favorite_id));
|
||||
setItems(values => {
|
||||
@@ -9552,14 +9571,37 @@ function Favorites({
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
disabled: !selected.length,
|
||||
onClick: deleteSelected,
|
||||
onClick: () => setConfirmingDelete(true),
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
|
||||
children: "delete_outline"
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", {
|
||||
children: "删除"
|
||||
}, void 0, false)]
|
||||
}, void 0, true)]
|
||||
}, void 0, true) : null]
|
||||
}, void 0, true) : null, confirmingDelete ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
|
||||
className: "library-sheet-backdrop",
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("section", {
|
||||
className: "library-confirm-sheet",
|
||||
role: "dialog",
|
||||
"aria-modal": "true",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
|
||||
children: "delete_outline"
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("h2", {
|
||||
children: "删除收藏"
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("p", {
|
||||
children: ["确定删除已选择的 ", selected.length, " 项收藏吗?"]
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
className: "danger",
|
||||
onClick: confirmDelete,
|
||||
children: "确定"
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
onClick: () => setConfirmingDelete(false),
|
||||
children: "取消"
|
||||
}, void 0, false)]
|
||||
}, void 0, true)
|
||||
}, void 0, false) : null]
|
||||
}, void 0, true);
|
||||
}
|
||||
function FavoriteShare({
|
||||
@@ -10844,7 +10886,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, topic) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
|
||||
hash = topic ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'topic', topic) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, 'topic');
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
|
||||
};
|
||||
@@ -10865,7 +10907,7 @@ const navigateToProfile = (topic, userId, member) => {
|
||||
} : {})
|
||||
});
|
||||
}
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'group-member-profile'), 'uid', userId));
|
||||
if (topic && userId) {
|
||||
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMemberProfile)(topic, userId).then(real => {
|
||||
const current = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)('profile-seed:' + userId) || {};
|
||||
@@ -13276,11 +13318,11 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, topic) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
|
||||
hash = topic ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, 'topic', topic) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, 'topic');
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
|
||||
};
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view));
|
||||
function frequentContacts(chatList) {
|
||||
if (!chatList || chatList.length === 0) return [];
|
||||
return chatList.filter(item => item.topic && tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Tinode.isP2PTopicName(item.topic)).slice().sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime()).slice(0, 8).map(item => ({
|
||||
@@ -14960,7 +15002,7 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
|
||||
class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const urlParams = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].parseUrlHash(window.location.hash).params;
|
||||
const urlParams = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl()).params;
|
||||
const initialTab = urlParams['tab'] || 'find';
|
||||
const initialQuery = initialTab == 'find' ? urlParams['q'] || null : null;
|
||||
this.state = {
|
||||
@@ -14978,7 +15020,7 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}
|
||||
handleTabClick(e) {
|
||||
e.preventDefault();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), 'tab', e.currentTarget.dataset.id));
|
||||
this.setState({
|
||||
tabSelected: e.currentTarget.dataset.id
|
||||
});
|
||||
@@ -15008,12 +15050,12 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}
|
||||
handleSearchResultSelected(topicName) {
|
||||
if (this.state.tabSelected == 'find') {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
}
|
||||
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(undefined, {
|
||||
public: (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_8__.theCard)(name, dataUrl, null, description),
|
||||
private: priv,
|
||||
@@ -15021,7 +15063,7 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
}, isChannel);
|
||||
}
|
||||
handleGroupByID(topicName) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
|
||||
this.props.onCreateTopic(topicName);
|
||||
}
|
||||
render() {
|
||||
@@ -15136,12 +15178,19 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
|
||||
});
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
|
||||
};
|
||||
const navigateReplace = (view, params) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
|
||||
});
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(hash);
|
||||
};
|
||||
const PERSONAL_SETTINGS_VIEWS = ['personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'change-password', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info'];
|
||||
function Icon({
|
||||
children
|
||||
@@ -15978,6 +16027,7 @@ function ChangePassword({
|
||||
actionDisabled: busy,
|
||||
onAction: submit
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("main", {
|
||||
className: "change-password-fields",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("label", {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("span", {
|
||||
children: "原密码"
|
||||
@@ -16321,7 +16371,7 @@ function YouthMode({
|
||||
}), 400);
|
||||
return;
|
||||
}
|
||||
navigate('youth-mode-verify', {
|
||||
navigateReplace('youth-mode-verify', {
|
||||
ym: 'enable'
|
||||
});
|
||||
};
|
||||
@@ -16354,7 +16404,7 @@ function YouthMode({
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
className: settings.enabled ? 'outline' : '',
|
||||
onClick: () => settings.enabled ? navigate('youth-mode-verify', {
|
||||
onClick: () => settings.enabled ? navigateReplace('youth-mode-verify', {
|
||||
ym: 'disable'
|
||||
}) : startEnable(),
|
||||
children: settings.enabled ? '关闭青少年模式' : '开启青少年模式'
|
||||
@@ -16406,7 +16456,7 @@ function YouthModeVerify({
|
||||
enabled: mode === 'enable'
|
||||
});
|
||||
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
|
||||
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
|
||||
window.setTimeout(() => navigateReplace(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
|
||||
}).catch(err => {
|
||||
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
|
||||
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
|
||||
@@ -16632,7 +16682,7 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].removeUrlParam(hash, key);
|
||||
});
|
||||
@@ -17149,14 +17199,10 @@ function UserFeedback({
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("section", {
|
||||
className: "feedback-upload",
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("header", {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("span", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("strong", {
|
||||
children: "图片"
|
||||
}, void 0, false)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("em", {
|
||||
children: [images.length, "/", maxFiles]
|
||||
}, void 0, true)]
|
||||
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
|
||||
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("strong", {
|
||||
children: ["图片(", images.length, "/", maxFiles, ")"]
|
||||
}, void 0, true)
|
||||
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
|
||||
type: "button",
|
||||
disabled: uploading,
|
||||
onClick: () => fileInputRef.current && fileInputRef.current.click(),
|
||||
@@ -18015,7 +18061,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
this.previewPrefetchAttempted = new Set();
|
||||
this.state = this.getBlankState();
|
||||
this.handleResize = this.handleResize.bind(this);
|
||||
this.handleHashRoute = this.handleHashRoute.bind(this);
|
||||
this.handleRouteChange = this.handleRouteChange.bind(this);
|
||||
this.handleOnline = this.handleOnline.bind(this);
|
||||
this.handleEscapeKey = this.handleEscapeKey.bind(this);
|
||||
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
|
||||
@@ -18208,7 +18254,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
this.handleOnline(false);
|
||||
};
|
||||
window.addEventListener('offline', this.handleOnlineOff);
|
||||
window.addEventListener('hashchange', this.handleHashRoute);
|
||||
window.addEventListener('popstate', this.handleRouteChange);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityEvent);
|
||||
document.addEventListener('keydown', this.handleEscapeKey);
|
||||
@@ -18256,7 +18302,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
}
|
||||
const parsedNav = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
|
||||
const parsedNav = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
|
||||
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
|
||||
this.setState({
|
||||
requestedTopic: parsedNav.path[1]
|
||||
@@ -18264,13 +18310,13 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
const path = parsedNav.params && parsedNav.params.cred_done ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam('', 'cred_done', parsedNav.params.cred_done) : '';
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(path);
|
||||
} else {
|
||||
this.handleHashRoute();
|
||||
this.handleRouteChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('hashchange', this.handleHashRoute);
|
||||
window.removeEventListener('popstate', this.handleRouteChange);
|
||||
window.removeEventListener('online', this.handleOnlineOn);
|
||||
window.removeEventListener('offline', this.handleOnlineOff);
|
||||
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
|
||||
@@ -18459,8 +18505,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
};
|
||||
};
|
||||
}
|
||||
handleHashRoute() {
|
||||
const hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
|
||||
handleRouteChange() {
|
||||
const hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
|
||||
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo('');
|
||||
return;
|
||||
@@ -18710,12 +18756,12 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
if (!Array.isArray(params.cred) || !params.cred.length) {
|
||||
return false;
|
||||
}
|
||||
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
|
||||
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
|
||||
parsed.path[0] = 'cred';
|
||||
parsed.params['method'] = params.cred[0];
|
||||
parsed.params['token'] = params.token;
|
||||
parsed.params['code'] = params.code;
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrlHash(parsed.path, parsed.params));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrl(parsed.path, parsed.params));
|
||||
return true;
|
||||
}
|
||||
handleLoginSuccessful() {
|
||||
@@ -18750,9 +18796,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
loadSpinnerVisible: false
|
||||
});
|
||||
});
|
||||
const currentView = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash).path[0];
|
||||
const currentView = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl()).path[0];
|
||||
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) || !SIDE_PANEL_VIEWS.includes(currentView);
|
||||
let urlHash = cameFromAuthFlow ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
|
||||
let urlHash = cameFromAuthFlow ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'workbench') : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl();
|
||||
if (goToTopic) {
|
||||
urlHash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(urlHash, goToTopic);
|
||||
}
|
||||
@@ -18964,7 +19010,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
mobilePanel: 'sidepanel'
|
||||
});
|
||||
if (!_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].goBack()) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, null));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), null));
|
||||
}
|
||||
}
|
||||
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
|
||||
@@ -19049,7 +19095,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
handleNewAccount() {
|
||||
this.handleError();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'register'));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'register'));
|
||||
}
|
||||
handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
|
||||
this.handleError();
|
||||
@@ -19122,7 +19168,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
handleSelectWallpapers() {
|
||||
this.handleError();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'wallpapers'));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'wallpapers'));
|
||||
}
|
||||
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
|
||||
document.documentElement.style.setProperty('--wallpaper-url', `url('${wallpaper}')`);
|
||||
@@ -19171,7 +19217,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
handleSettings() {
|
||||
this.handleError();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings'));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'edit' : 'settings'));
|
||||
}
|
||||
handleGlobalSettings(settings) {
|
||||
const serverAddress = settings.serverAddress || this.state.serverAddress;
|
||||
@@ -19198,7 +19244,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
transport: transport,
|
||||
secureConnection: secureConnection
|
||||
});
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
|
||||
}
|
||||
handleTenantResolved(data, requestedCode) {
|
||||
const tenant = data.tenant || {};
|
||||
@@ -19225,10 +19271,10 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
}
|
||||
}
|
||||
handleShowArchive() {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'archive' : ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'archive' : ''));
|
||||
}
|
||||
handleShowBlocked() {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'blocked' : ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'blocked' : ''));
|
||||
}
|
||||
toggleFCMToken(enabled) {
|
||||
if (enabled) {
|
||||
@@ -19299,7 +19345,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
});
|
||||
}
|
||||
handleSidepanelCancel() {
|
||||
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
|
||||
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
|
||||
let path = '';
|
||||
if (['security', 'support', 'general', 'notif', 'acc_general'].includes(parsed.path[0])) {
|
||||
path = 'edit';
|
||||
@@ -19320,17 +19366,17 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
delete parsed.params.scheme;
|
||||
delete parsed.params.token;
|
||||
}
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrlHash(parsed.path, parsed.params));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrl(parsed.path, parsed.params));
|
||||
this.setState({
|
||||
errorText: '',
|
||||
errorLevel: null
|
||||
});
|
||||
}
|
||||
basicNavigator(hash) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, hash));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), hash));
|
||||
}
|
||||
infoNavigator(hash) {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlInfoPanel(window.location.hash, hash));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlInfoPanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), hash));
|
||||
}
|
||||
handleStartTopicRequest(topicName, newTopicParams, isChannel) {
|
||||
if (topicName && this.tinode.isTopicCached(topicName)) {
|
||||
@@ -19499,7 +19545,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
return;
|
||||
}
|
||||
topic.delTopic(true).then(_ => {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -19517,7 +19563,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
return;
|
||||
}
|
||||
topic.leave(true).then(_ => {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
|
||||
}).catch(err => {
|
||||
this.handleError(err.message, 'err');
|
||||
});
|
||||
@@ -19528,7 +19574,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
return;
|
||||
}
|
||||
topic.updateMode(null, '-JP').then(_ => {
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
|
||||
}).catch(err => this.handleError(err.message, 'err'));
|
||||
}
|
||||
handleReportTopic(topicName) {
|
||||
@@ -19549,7 +19595,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
target_name: targetName
|
||||
};
|
||||
topic.updateMode(null, '-JP').then(_ => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, '');
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), '');
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(hash, 'abuse-report-reasons');
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, key, value);
|
||||
@@ -19763,7 +19809,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
can_add_friend: !!data.user.can_add
|
||||
}
|
||||
});
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, '');
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), '');
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
|
||||
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, 'uid', data.user.user_id);
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
|
||||
@@ -19830,7 +19876,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
|
||||
return;
|
||||
}
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(window.location.hash, 'info', 'info'));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'info', 'info'));
|
||||
this.setState({
|
||||
infoPanel: 'info'
|
||||
});
|
||||
@@ -19861,7 +19907,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
|
||||
meth: cred,
|
||||
resp: code
|
||||
}
|
||||
}).then(_ => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'contacts'))).catch(err => this.handleError(err.message, 'err'));
|
||||
}).then(_ => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'contacts'))).catch(err => this.handleError(err.message, 'err'));
|
||||
} else {
|
||||
this.setState({
|
||||
credMethod: cred,
|
||||
@@ -20622,13 +20668,13 @@ __webpack_require__.r(__webpack_exports__);
|
||||
|
||||
|
||||
const navigate = (view, params) => {
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
|
||||
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
|
||||
});
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
|
||||
};
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view));
|
||||
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view));
|
||||
const WORKPLACE_COLLABORATION_VIEWS = ['workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'];
|
||||
function Icon({
|
||||
children
|
||||
@@ -70732,7 +70778,7 @@ const messageLoader = {
|
||||
const rtl = ['ar', 'fa', 'he', 'ur'];
|
||||
const {
|
||||
params
|
||||
} = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrlHash(window.location.hash);
|
||||
} = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].currentUrl());
|
||||
const language = params && params.hl || navigator.languages && navigator.languages[0] || navigator.language || navigator.userLanguage || 'en';
|
||||
const normalized = language.replace('_', '-');
|
||||
const baseLanguage = normalized.split('-')[0].toLowerCase();
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1828,7 +1828,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
|
||||
if (!topic || !seq || isNaN(seq)) {
|
||||
return;
|
||||
}
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].removeUrlParam(window.location.hash, 'jumpseq'));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].removeUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'jumpseq'));
|
||||
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
|
||||
if (topic.findMessage(seq)) {
|
||||
jump();
|
||||
@@ -2715,6 +2715,10 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
|
||||
this.handleQuoteClick = this.handleQuoteClick.bind(this);
|
||||
this.handleContextClick = this.handleContextClick.bind(this);
|
||||
this.handleTouchStart = this.handleTouchStart.bind(this);
|
||||
this.handleTouchMove = this.handleTouchMove.bind(this);
|
||||
this.handleTouchEnd = this.handleTouchEnd.bind(this);
|
||||
this.suppressNativeContextMenu = this.suppressNativeContextMenu.bind(this);
|
||||
this.handleCancelUpload = this.handleCancelUpload.bind(this);
|
||||
this.handleDraftyClick = this.handleDraftyClick.bind(this);
|
||||
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
|
||||
@@ -2753,7 +2757,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
break;
|
||||
case 'contact_find':
|
||||
e.preventDefault();
|
||||
let hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].setUrlSidePanel(window.location.hash, 'newtpk');
|
||||
let hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].currentUrl(), 'newtpk');
|
||||
hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].addUrlParam(hashUrl, 'q', e.target.dataset.val);
|
||||
hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].addUrlParam(hashUrl, 'tab', 'find');
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].navigateTo(hashUrl);
|
||||
@@ -2803,9 +2807,53 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
const text = e.target.dataset.title || 'unknown';
|
||||
this.props.onFormResponse(e.target.dataset.act, text, data);
|
||||
}
|
||||
handleTouchStart(e) {
|
||||
if (!this.props.showContextMenu || this.props.selectMode) {
|
||||
return;
|
||||
}
|
||||
const touch = e.touches && e.touches[0];
|
||||
this.longPressOrigin = {
|
||||
x: touch ? touch.pageX : e.pageX,
|
||||
y: touch ? touch.pageY : e.pageY
|
||||
};
|
||||
this.longPressTimer = window.setTimeout(() => {
|
||||
this.longPressTimer = null;
|
||||
this.openContextMenu(this.longPressOrigin.x, this.longPressOrigin.y);
|
||||
}, 500);
|
||||
}
|
||||
handleTouchMove(e) {
|
||||
if (!this.longPressOrigin) {
|
||||
return;
|
||||
}
|
||||
const touch = e.touches && e.touches[0];
|
||||
const x = touch ? touch.pageX : e.pageX;
|
||||
const y = touch ? touch.pageY : e.pageY;
|
||||
if (Math.abs(x - this.longPressOrigin.x) > 10 || Math.abs(y - this.longPressOrigin.y) > 10) {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
}
|
||||
handleTouchEnd() {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
cancelLongPress() {
|
||||
if (this.longPressTimer) {
|
||||
window.clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.longPressOrigin = null;
|
||||
}
|
||||
suppressNativeContextMenu(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
componentWillUnmount() {
|
||||
this.cancelLongPress();
|
||||
}
|
||||
handleContextClick(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.openContextMenu(e.pageX, e.pageY);
|
||||
}
|
||||
openContextMenu(x, y) {
|
||||
const menuItems = [];
|
||||
if (this.props.received == tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.MESSAGE_STATUS_FAILED) {
|
||||
menuItems.push('menu_item_send_retry');
|
||||
@@ -2842,8 +2890,8 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
head: this.props.head,
|
||||
userFrom: this.props.userFrom,
|
||||
userName: this.props.userName,
|
||||
y: e.pageY,
|
||||
x: e.pageX,
|
||||
y: y,
|
||||
x: x,
|
||||
pickReply: this.props.pickReply,
|
||||
editMessage: this.props.editMessage,
|
||||
timestamp: this.props.timestamp
|
||||
@@ -3051,6 +3099,10 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
|
||||
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
|
||||
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
|
||||
className: bubbleClass,
|
||||
onTouchStart: this.handleTouchStart,
|
||||
onTouchMove: this.handleTouchMove,
|
||||
onTouchEnd: this.handleTouchEnd,
|
||||
onContextMenu: this.suppressNativeContextMenu,
|
||||
children: [showVoiceUnreadDot ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("i", {
|
||||
className: "voice-unread-dot"
|
||||
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -46,7 +46,7 @@ class WallpapersView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
|
||||
}
|
||||
handleTabClick(e) {
|
||||
e.preventDefault();
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
|
||||
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), 'tab', e.currentTarget.dataset.id));
|
||||
this.setState({
|
||||
tab: e.currentTarget.dataset.id
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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>MeetingIM</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3083
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",
|
||||
"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-intl": "^7.1.6",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#1677ff"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-weight="700" font-size="19" fill="#fff">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
+102
@@ -0,0 +1,102 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||
|
||||
import { AppShell } from '@/app/AppShell';
|
||||
import { RequireAuth } from '@/app/RequireAuth';
|
||||
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
|
||||
import { LoginPage } from '@/features/auth/LoginPage';
|
||||
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();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: 'tenant', element: <TenantEntryPage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'reset-password', element: <ResetPasswordPage /> },
|
||||
{ path: 'enterprise-register', element: <EnterpriseRegisterPage /> },
|
||||
{ path: 'feedback', element: <TenantFeedbackPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <AppShell />,
|
||||
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} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { CallRoot } from '@/features/calls/CallRoot';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { useChatList } from '@/lib/tinode/hooks';
|
||||
import { initCallSignaling } from '@/stores/calls';
|
||||
|
||||
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 contacts = useChatList();
|
||||
const bootstrap = useBootstrap();
|
||||
// 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();
|
||||
}, []);
|
||||
// "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);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
|
||||
<div className="hidden sm:flex sm:h-full">
|
||||
<NavRail unreadMessages={unreadMessages} />
|
||||
</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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
|
||||
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
|
||||
* used for chat attachments) expects.
|
||||
*/
|
||||
export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) {
|
||||
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
|
||||
if (remote.url) {
|
||||
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<span className={`grid place-items-center font-medium text-white ${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,11 @@
|
||||
/** 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`. */
|
||||
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
|
||||
{name}
|
||||
</i>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
|
||||
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
|
||||
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">
|
||||
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
|
||||
{selfInitial}
|
||||
</div>
|
||||
<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,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { restoreSession } from '@/lib/tinode/connection';
|
||||
import { isTenantMismatch } from '@/lib/tinode/errors';
|
||||
import { 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]);
|
||||
|
||||
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 ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
|
||||
<a href="/login" className="text-accent no-underline">
|
||||
改为重新登录
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -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,147 @@
|
||||
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) => setFile(e.target.files?.[0] || null)}
|
||||
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,127 @@
|
||||
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 } 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) {
|
||||
// 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">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-[22px] font-bold text-accent">M</div>
|
||||
<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 className="absolute bottom-14 left-16 flex gap-7 text-[13px] text-white/75">
|
||||
<span>下载桌面客户端</span>
|
||||
<span>iOS / Android</span>
|
||||
<span>帮助中心</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col bg-white px-6 py-8">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
|
||||
M
|
||||
</div>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</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 text-[22px] font-bold text-text-title">重置密码</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,106 @@
|
||||
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 { resetTinode } from '@/lib/tinode/connection';
|
||||
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 [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!code.trim() || 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.
|
||||
if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await resolveTenant(code.trim());
|
||||
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: code.trim() }} 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">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-[26px] font-bold text-text-title">MeetingIM</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={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入企业码"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!code.trim() || 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: code.trim() }} 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,76 @@
|
||||
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';
|
||||
onChallenge: (challengeId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
|
||||
export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) {
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// 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(() => {
|
||||
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;
|
||||
setBusy(true);
|
||||
try {
|
||||
const data = await requestVerificationCode(scene, phone);
|
||||
onChallenge(data.challenge_id);
|
||||
tick(data.resend_after_seconds || 60);
|
||||
} catch (err) {
|
||||
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,112 @@
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { 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, unlike
|
||||
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
|
||||
* a display name via the chat list, same lookup ChatListColumn itself uses.
|
||||
*/
|
||||
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">
|
||||
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
|
||||
<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>
|
||||
) : 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;
|
||||
const name = contacts.find((c) => c.topic === item.peer_user_id)?.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,422 @@
|
||||
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, 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>
|
||||
<span className="text-xs text-success">{connectedAt ? `通话中 · ${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,
|
||||
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 />;
|
||||
|
||||
const statusLabel = phase === 'connected' ? `${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' ? (
|
||||
<>
|
||||
{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' ? '对方已关闭摄像头' : ''}</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' ? 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' ? <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 };
|
||||
case 'declined':
|
||||
return { label: '对方已拒绝', 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,302 @@
|
||||
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';
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 50;
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
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 flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
|
||||
>
|
||||
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
|
||||
{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.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={() => {
|
||||
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-full 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 { 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>
|
||||
) : 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 ? (
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
|
||||
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : 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,173 @@
|
||||
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-8 w-8 place-items-center text-text-heading">
|
||||
<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>
|
||||
) : 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,249 @@
|
||||
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; every other failure (404, network error) keeps that fallback.
|
||||
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
|
||||
return (
|
||||
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
|
||||
{backLink}
|
||||
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
|
||||
</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,111 @@
|
||||
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>
|
||||
) : 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">
|
||||
{g.member_count} 人 · 群号 {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,122 @@
|
||||
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';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
|
||||
|
||||
/** 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>
|
||||
) : 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,154 @@
|
||||
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()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.data || !canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
// 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('二维码生成失败'));
|
||||
}, [query.data]);
|
||||
|
||||
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>
|
||||
</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,517 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
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';
|
||||
|
||||
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.
|
||||
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
|
||||
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 ? (
|
||||
<span
|
||||
className={`absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full text-[13px] text-white ${
|
||||
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'
|
||||
}`}
|
||||
>
|
||||
{selected ? <Icon name="check" className="text-[14px]" /> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border-list-item 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();
|
||||
exitSelectMode();
|
||||
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
|
||||
},
|
||||
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);
|
||||
exitSelectMode();
|
||||
// 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 hides that.
|
||||
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
|
||||
},
|
||||
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 = () => {
|
||||
if (selected.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) 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-body'
|
||||
}`}
|
||||
>
|
||||
{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]" />
|
||||
<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,198 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
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>>;
|
||||
try {
|
||||
result = await createGroup({
|
||||
name: name.trim(),
|
||||
member_user_ids: [...selected.keys()],
|
||||
});
|
||||
} catch (err) {
|
||||
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"
|
||||
>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${
|
||||
checked ? 'bg-accent text-white' : 'border-[1.5px] border-[#c5cad2]'
|
||||
}`}
|
||||
>
|
||||
{checked ? <Icon name="check" className="text-[13px]" /> : null}
|
||||
</span>
|
||||
<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,490 @@
|
||||
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 {
|
||||
addGroupAdmins,
|
||||
getAllGroupMembers,
|
||||
getGroup,
|
||||
getGroupGovernance,
|
||||
getGroupMutes,
|
||||
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 { 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: () => getGroupMutes(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?.items || []} 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}
|
||||
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}
|
||||
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}
|
||||
/>
|
||||
</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_private_chat ? 'all' : 'friends'}
|
||||
options={[
|
||||
{ value: 'all', label: '允许' },
|
||||
{ value: 'friends', label: '仅好友' },
|
||||
]}
|
||||
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
|
||||
disabled={!govQuery.data || govMutation.isPending}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</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?.items.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(m.display_name)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-text-body">{m.display_name || m.user_id}</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(a.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{a.display_name || a.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMutation.mutate(a.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="text-xs text-danger-text 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 MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
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) => (
|
||||
<div key={m.user.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.user_id)}`}>
|
||||
{avatarLetter(m.user.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => unmuteMutation.mutate(m.user.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: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
|
||||
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={(m) => {
|
||||
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
|
||||
}}
|
||||
emptyLabel="没有可转让的成员"
|
||||
disabled={transferMutation.isPending}
|
||||
/>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</SubModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { getAllGroupMembers, getLatestGroupAnnouncement, 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.
|
||||
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
|
||||
* than kept as permanently-dead optional-chained code. 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).
|
||||
*/
|
||||
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);
|
||||
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Toast message={error} variant="error" onDone={() => setError('')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useLayoutEffect, 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 { 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 {
|
||||
disbandGroup,
|
||||
getGroup,
|
||||
getMyGroupProfile,
|
||||
getGroupMembers,
|
||||
getGroupSettings,
|
||||
getGroupSummary,
|
||||
leaveGroup,
|
||||
updateGroupMemberSettings,
|
||||
updateMyGroupNickname,
|
||||
type GroupMember,
|
||||
type GroupSettings,
|
||||
} from '@/lib/api/groups';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
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('');
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [openModal, setOpenModal] = useState<'members' | '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('');
|
||||
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, {}) });
|
||||
// `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
|
||||
// only this per-user profile endpoint does, so the nickname row needs its own fetch. 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;
|
||||
// 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;
|
||||
|
||||
// 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] });
|
||||
setNicknameEditing(false);
|
||||
},
|
||||
onError: (err) => showError(err, '昵称保存失败'),
|
||||
});
|
||||
|
||||
const leaveMutation = useMutation({
|
||||
mutationFn: () => leaveGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onError: (err) => showError(err, '退出群聊失败'),
|
||||
});
|
||||
|
||||
const disbandMutation = useMutation({
|
||||
mutationFn: () => disbandGroup(topicId),
|
||||
onSuccess: () => navigate('/messages'),
|
||||
onError: (err) => showError(err, '解散群聊失败'),
|
||||
});
|
||||
|
||||
const clearHistory = () => {
|
||||
if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
const startLeaveOrDisband = () => {
|
||||
if (isOwner) {
|
||||
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
|
||||
disbandMutation.mutate();
|
||||
} else {
|
||||
if (!window.confirm('确定退出本群聊吗?')) return;
|
||||
leaveMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const members = membersQuery.data?.items || [];
|
||||
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
|
||||
|
||||
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">
|
||||
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(groupQuery.data?.name || peerName)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||||
<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) => (
|
||||
<div key={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). */}
|
||||
<span
|
||||
className={`grid h-11 w-11 place-items-center rounded-lg text-sm font-medium text-white md:h-10 md:w-10 ${avatarBgClass(m.user_id)}`}
|
||||
>
|
||||
{avatarLetter(m.display_name)}
|
||||
</span>
|
||||
<RoleCorner role={m.role} />
|
||||
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
|
||||
</div>
|
||||
))}
|
||||
{canInvite ? (
|
||||
<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="add" 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' ? (
|
||||
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
|
||||
) : null}
|
||||
{openModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
|
||||
) : null}
|
||||
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} 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,297 @@
|
||||
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 { 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;
|
||||
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, 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();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.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. */}
|
||||
<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>
|
||||
<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))}
|
||||
onClose={() => setOpenMenuId(null)}
|
||||
onDownload={() => downloadFile(f)}
|
||||
onOpen={() => openFileInNewTab(f)}
|
||||
onDelete={() => {
|
||||
setOpenMenuId(null);
|
||||
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) 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,144 @@
|
||||
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 { getAllGroupMembers, inviteGroupMembers, type GroupRole } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useEscapeKey } from '@/lib/hooks';
|
||||
|
||||
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;
|
||||
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). No "移出成员" affordance here: this protocol has
|
||||
* no documented REST or WS operation for removing a group member at all (grepped the whole
|
||||
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
|
||||
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState('');
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEscapeKey(onClose);
|
||||
|
||||
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
|
||||
const members = membersQuery.data || [];
|
||||
const filtered = members.filter((m) => !query.trim() || (m.display_name || m.user_id).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: () => {
|
||||
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;
|
||||
return (
|
||||
<button
|
||||
key={f.user_id}
|
||||
type="button"
|
||||
disabled={inviteMutation.isPending || f.alreadyMember}
|
||||
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 ${
|
||||
f.alreadyMember ? '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>
|
||||
{f.alreadyMember ? (
|
||||
<span className="flex-none text-xs text-text-tertiary-3">已在群内</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">
|
||||
<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(m.display_name)}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
|
||||
<RoleTag role={m.role} />
|
||||
</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,183 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { useAuthedBlobUrl } from '@/lib/hooks';
|
||||
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
|
||||
|
||||
interface ImageData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function ImageAttachment({ data }: { data: ImageData }) {
|
||||
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;
|
||||
|
||||
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.
|
||||
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
|
||||
}
|
||||
|
||||
interface FileData {
|
||||
val?: string;
|
||||
ref?: string;
|
||||
mime?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function FileAttachment({ data }: { data: FileData }) {
|
||||
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 (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;
|
||||
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. */
|
||||
export function VideoAttachment({ data }: { data: VideoData }) {
|
||||
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);
|
||||
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={() => setPlaying(true)}
|
||||
disabled={!videoSrc}
|
||||
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
|
||||
>
|
||||
{posterInline ? (
|
||||
<img src={posterInline} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<Icon name={remote.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;
|
||||
}
|
||||
|
||||
export function AudioAttachment({ data }: { data: AudioData }) {
|
||||
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 (!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,194 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
|
||||
import { fetchAllPages } from '@/lib/api/client';
|
||||
import { getGroups } from '@/lib/api/groups';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
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);
|
||||
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 [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.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.public?.fn || contact.topic;
|
||||
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
|
||||
const active = topicId === contact.topic;
|
||||
const isGroup = tinode.getTopic(contact.topic).isGroupType();
|
||||
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
|
||||
const muted = !!contact.private?.mute_notifications;
|
||||
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 grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
|
||||
>
|
||||
{avatarLetter(name)}
|
||||
{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' ? (
|
||||
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import type { StoredMessage } from 'tinode-sdk';
|
||||
import { Drafty } from 'tinode-sdk';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
import { Toast } from '@/app/Toast';
|
||||
import { EmojiPanel } from '@/features/messages/EmojiPanel';
|
||||
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
|
||||
import { MentionPicker } from '@/features/messages/MentionPicker';
|
||||
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
|
||||
import { MessageRow } from '@/features/messages/MessageBubble';
|
||||
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
|
||||
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
|
||||
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
|
||||
import { useBootstrap } from '@/lib/api/hooks';
|
||||
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
|
||||
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
|
||||
import { useClickOutside } from '@/lib/hooks';
|
||||
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
|
||||
import { isRecalled } from '@/lib/messages/dispatch';
|
||||
import { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
|
||||
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
|
||||
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
|
||||
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
|
||||
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
import { useMessageJumpStore } from '@/stores/messageJump';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
interface OutletCtx {
|
||||
onToggleDetail: () => void;
|
||||
/** Whether the detail panel is CURRENTLY shown -- at >=1280px this is the persisted
|
||||
* (localStorage) docked-panel state, at 960-1279px the temporary drawer state. Drives the
|
||||
* header "i" button's highlight. */
|
||||
detailVisible: boolean;
|
||||
/** Opens the shared history-search modal (owned by MessagesLayout, the closest common ancestor of
|
||||
* this view and DetailColumn, which can also open it from its own "查找聊天记录" row). */
|
||||
onOpenHistory: (tab: 'text' | MediaType) => void;
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
msg: StoredMessage;
|
||||
}
|
||||
|
||||
function notImplemented() {
|
||||
window.alert('该功能暂未开放,如需联系客服。');
|
||||
}
|
||||
|
||||
export function ConversationView() {
|
||||
const { topicId } = useParams();
|
||||
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
|
||||
const myUserId = useSessionStore((s) => s.myUserId);
|
||||
const tinode = useTinode();
|
||||
const contacts = useChatList();
|
||||
const contact = contacts.find((c) => c.topic === topicId);
|
||||
const messages = useTopicMessages(topicId);
|
||||
const bootstrap = useBootstrap();
|
||||
// Keyed by topicId, not a single string -- ConversationView is one long-lived instance reused
|
||||
// across every conversation (the route is `:topicId`, React Router doesn't remount on param
|
||||
// change), so a plain single draft leaks the PREVIOUS conversation's unsent text into whichever
|
||||
// one you open next.
|
||||
const [draftByTopic, setDraftByTopic] = useState<Record<string, string>>({});
|
||||
const draft = (topicId && draftByTopic[topicId]) || '';
|
||||
const setDraft = (value: string) => {
|
||||
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
|
||||
};
|
||||
// Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while
|
||||
// composing in group A must not still be "pending" if the user switches to group B and back
|
||||
// before sending (buildMentionedContent would then splice a real MN entity for a name that's
|
||||
// just inert leftover text in a completely different conversation's draft).
|
||||
const [mentionsByTopic, setMentionsByTopic] = useState<Record<string, PendingMention[]>>({});
|
||||
const pendingMentions = (topicId && mentionsByTopic[topicId]) || [];
|
||||
const [mentionTrigger, setMentionTrigger] = useState<{ start: number; query: string } | null>(null);
|
||||
const [groupModal, setGroupModal] = useState<'announcement' | 'join-requests' | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [menu, setMenu] = useState<ContextMenuState | null>(null);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [bulkForwardOpen, setBulkForwardOpen] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
|
||||
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
|
||||
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||
const emojiContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mentionContainerRef = useRef<HTMLDivElement>(null);
|
||||
// Neither popover had a way to dismiss except re-clicking its own toggle -- a real gap a user
|
||||
// report caught (open the emoji panel, click elsewhere in the app, and it just sits there).
|
||||
useClickOutside(emojiContainerRef, () => setEmojiOpen(false), emojiOpen);
|
||||
useClickOutside(mentionContainerRef, () => setMentionTrigger(null), !!mentionTrigger);
|
||||
const [errorToast, setErrorToast] = useState('');
|
||||
const [successToast, setSuccessToast] = useState('');
|
||||
const streamRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
// "Jump to message" (HistorySearchModal result click, via stores/messageJump.ts). `highlightedSeq`
|
||||
// drives MessageRow's brief flash; `pendingJumpSeqRef` is a ref (not state) because it's read/written
|
||||
// across two different effects below without needing a render of its own.
|
||||
const jumpTarget = useMessageJumpStore((s) => s.target);
|
||||
const [highlightedSeq, setHighlightedSeq] = useState<number | null>(null);
|
||||
const pendingJumpSeqRef = useRef<number | null>(null);
|
||||
// Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
|
||||
// value, which would false-positive if the user opens A, then B, then A again within a single
|
||||
// loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
|
||||
// down and rebuilt by the intervening open/close cycle, so a same-topicId check alone would wrongly
|
||||
// treat that as "still the same open" and apply a scroll-restore computed against content that
|
||||
// isn't there anymore.
|
||||
const openGenerationRef = useRef(0);
|
||||
|
||||
// Every "fire and forget" send/recall/favorite/forward action below used to swallow its own
|
||||
// failure with an empty `.catch(() => {})` (or, for forward, no catch at all) -- e.g. an
|
||||
// over-size-limit attachment would throw a friendly Chinese error from uploadFile() that then
|
||||
// just vanished, leaving the user staring at nothing happening with no idea why.
|
||||
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
|
||||
// docs/IM-前后端对接文档.md §4.16: "成功 Toast 只在至少一项成功时展示,部分失败需给出汇总" -- this
|
||||
// response's `created_count`/`existing_count`/`failed[]` were being resolved and thrown away
|
||||
// entirely, so favoriting from a message's context menu or the multi-select bar gave the user
|
||||
// total silence either way: no confirmation on full success, and no way to learn that some of
|
||||
// several selected messages weren't actually favorited.
|
||||
const notifyFavoriteResult = (result: { created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }) => {
|
||||
if (result.created_count + result.existing_count > 0) setSuccessToast('已收藏');
|
||||
if (result.failed.length) setErrorToast(`${result.failed.length} 条消息收藏失败`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId) return undefined;
|
||||
openConversation(topicId).catch(() => {});
|
||||
return () => {
|
||||
closeConversation(topicId).catch(() => {});
|
||||
};
|
||||
}, [topicId]);
|
||||
|
||||
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
|
||||
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
|
||||
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
|
||||
// list underneath, self-correcting only on the next tick.
|
||||
useLayoutEffect(() => {
|
||||
openGenerationRef.current += 1;
|
||||
setSelectMode(false);
|
||||
setSelectedSeqs(new Set());
|
||||
setMenu(null);
|
||||
setReplyTo(null);
|
||||
setForwardMsg(null);
|
||||
setBulkForwardOpen(false);
|
||||
setEmojiOpen(false);
|
||||
// Without this, a mention picker left open while typing in group A reappears in group B after a
|
||||
// fast conversation switch (isGroup is true there too), anchored to a caret offset that describes
|
||||
// A's draft, not B's -- picking a suggestion would splice "@Name" into the wrong text at the wrong
|
||||
// position.
|
||||
setMentionTrigger(null);
|
||||
// Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this,
|
||||
// a recording started in conversation A silently keeps going after navigating to B, and its
|
||||
// onSend callback (re-created every render off the CURRENT topicId) would publish the eventual
|
||||
// recording into B instead of the conversation the user was actually recording in.
|
||||
setRecording(false);
|
||||
// Without these two, a "加载更多" in flight when the user switches conversations leaves a
|
||||
// stale spinner showing (never resolves against the topic it started on) and, if the PREVIOUS
|
||||
// conversation had been scrolled up when it was left, the ref carries that `false` into the
|
||||
// NEW conversation and silently skips scrolling it to the bottom on open.
|
||||
setLoadingOlder(false);
|
||||
autoScrollRef.current = true;
|
||||
// An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
|
||||
// showing over whichever conversation is now open, misattributing the failure.
|
||||
setErrorToast('');
|
||||
setSuccessToast('');
|
||||
// A jump-to-message request still pending when the user switches away (its fetch was in flight,
|
||||
// or it resolved but the scroll-on-mount effect hadn't fired yet) would otherwise survive into
|
||||
// the NEW conversation and, once ITS messages populate, try to scroll+highlight whatever row in
|
||||
// B happens to share the SAME seq number the user searched for in A -- seq is per-topic, so a
|
||||
// collision across two different conversations is completely ordinary, not a rare edge case.
|
||||
pendingJumpSeqRef.current = null;
|
||||
setHighlightedSeq(null);
|
||||
}, [topicId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current) {
|
||||
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
|
||||
}
|
||||
// `topicId` is included so switching between two conversations that both happen to have
|
||||
// already loaded the same number of messages (e.g. both exactly MESSAGES_PAGE on first open)
|
||||
// still scrolls the newly-opened one to the bottom -- `messages.length` alone wouldn't change.
|
||||
}, [messages.length, topicId]);
|
||||
|
||||
// Consumes a pending "jump to message" request (HistorySearchModal result click). Fetches directly
|
||||
// via `since`/`before` (arbitrary absolute seq bounds per the Tinode wire protocol, not required to
|
||||
// be adjacent to whatever's already cached) rather than routing through loadOlder()'s gap-fill --
|
||||
// that machinery is scoped to backfilling holes within the currently-visible min/max span, not
|
||||
// "there's an entirely separate old cluster somewhere else the user wants to see right now".
|
||||
useEffect(() => {
|
||||
if (!jumpTarget || jumpTarget.topic !== topicId) return;
|
||||
useMessageJumpStore.getState().clear();
|
||||
const seq = jumpTarget.seq;
|
||||
pendingJumpSeqRef.current = seq;
|
||||
if (messages.some((m) => m.seq === seq)) return; // already loaded -- the effect below scrolls to it
|
||||
autoScrollRef.current = false; // otherwise the scroll-to-bottom effect above fights this jump
|
||||
const t = tinode.getTopic(topicId);
|
||||
const half = Math.floor(MESSAGES_PAGE / 2);
|
||||
t.getMeta(t.startMetaQuery().withData(Math.max(1, seq - half), seq + half + 1, MESSAGES_PAGE).build()).catch(() => {
|
||||
pendingJumpSeqRef.current = null;
|
||||
showError(null, '未能加载该消息,可能已被删除');
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jumpTarget, topicId]);
|
||||
|
||||
// Performs the actual scroll+highlight once the target row exists in the DOM -- covers both the
|
||||
// "already loaded" case (this and the effect above run in the same commit, so the ref is already
|
||||
// set by the time this checks) and the "had to fetch first" case (re-fires once `messages` picks up
|
||||
// the newly-arrived data via useTopicMessages' own subscription, no manual wiring needed here).
|
||||
useEffect(() => {
|
||||
const seq = pendingJumpSeqRef.current;
|
||||
if (seq == null) return;
|
||||
const el = streamRef.current?.querySelector(`[data-seq="${seq}"]`);
|
||||
if (!el) return;
|
||||
pendingJumpSeqRef.current = null;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
setHighlightedSeq(seq);
|
||||
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
|
||||
}, [messages, jumpTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!topicId || messages.length === 0) return;
|
||||
tinode.getTopic(topicId).noteRead(0);
|
||||
}, [topicId, messages.length, tinode]);
|
||||
|
||||
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
|
||||
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
|
||||
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
|
||||
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
|
||||
const membersQuery = useQuery({
|
||||
queryKey: ['group-members', topicId],
|
||||
queryFn: () => getAllGroupMembers(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const groupProfileQuery = useQuery({
|
||||
queryKey: ['group', topicId],
|
||||
queryFn: () => getGroup(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const groupSummaryQuery = useQuery({
|
||||
queryKey: ['group-summary', topicId],
|
||||
queryFn: () => getGroupSummary(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const governanceQuery = useQuery({
|
||||
queryKey: ['group-governance', topicId],
|
||||
queryFn: () => getGroupGovernance(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const mutesQuery = useQuery({
|
||||
queryKey: ['group-mutes', topicId],
|
||||
queryFn: () => getGroupMutes(topicId || ''),
|
||||
enabled: isGroup,
|
||||
});
|
||||
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
|
||||
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
|
||||
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
|
||||
const groupSublineParts = [
|
||||
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
|
||||
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
if (!topicId) return null;
|
||||
const topic = tinode.getTopic(topicId);
|
||||
const peerName = contact?.public?.fn || topicId;
|
||||
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
|
||||
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
|
||||
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
|
||||
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
|
||||
// regardless of who actually sent the quoted message. `memberByUserId` already resolves this
|
||||
// correctly per-bubble (senderName below); reused here for the identical lookup rather than a
|
||||
// second, divergent resolution path.
|
||||
const quotedSenderLabel = (fromUserId: string | undefined) => {
|
||||
if (myUserId && fromUserId === myUserId) return '我';
|
||||
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
|
||||
return peerName;
|
||||
};
|
||||
|
||||
/**
|
||||
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
|
||||
* which seq ranges are actually missing from the local cache within the currently-visible window
|
||||
* (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages
|
||||
* can have server-side deletions punching holes in an otherwise-contiguous range.
|
||||
*/
|
||||
const loadOlder = async () => {
|
||||
if (loadingOlder) return;
|
||||
const calledForGeneration = openGenerationRef.current;
|
||||
const el = streamRef.current;
|
||||
const min = topic.minMsgSeq();
|
||||
const max = topic.maxMsgSeq();
|
||||
const gaps = topic.msgHasMoreMessages(min, max, false);
|
||||
if (gaps.length === 0) return;
|
||||
setLoadingOlder(true);
|
||||
const prevHeight = el?.scrollHeight ?? 0;
|
||||
const prevTop = el?.scrollTop ?? 0;
|
||||
try {
|
||||
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
|
||||
} finally {
|
||||
// If the conversation was closed and reopened (even back to this SAME topicId) while this was
|
||||
// in flight, `el` now shows content from a different open/close cycle -- prevTop/prevHeight
|
||||
// were measured against content that isn't there anymore, so applying them would jump the
|
||||
// current conversation's scroll to a meaningless spot. Comparing topicId alone would miss an
|
||||
// A->B->A round trip (same string, different generation); only the specific open this request
|
||||
// was made for should ever clear its own loading flag or touch the scroll position.
|
||||
if (openGenerationRef.current === calledForGeneration) {
|
||||
setLoadingOlder(false);
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = streamRef.current;
|
||||
if (!el) return;
|
||||
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (el.scrollTop <= FETCH_PAGE_TRIGGER) {
|
||||
loadOlder();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setDraft('');
|
||||
setMentionTrigger(null);
|
||||
if (replyTo) {
|
||||
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that also
|
||||
// pings someone is a rare enough compound case that it isn't worth a second stitching path;
|
||||
// the "@name" text still sends fine, just without the entity/highlight.
|
||||
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
|
||||
setReplyTo(null);
|
||||
} else if (pendingMentions.length > 0) {
|
||||
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
|
||||
} else {
|
||||
sendText(topic, text).catch((err) => showError(err, '发送失败'));
|
||||
}
|
||||
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
|
||||
};
|
||||
|
||||
const handleDraftChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setDraft(value);
|
||||
if (isGroup) setMentionTrigger(detectMentionTrigger(value, e.target.selectionStart ?? value.length));
|
||||
};
|
||||
|
||||
const pickMention = (choice: PendingMention) => {
|
||||
if (!mentionTrigger || !topicId) return;
|
||||
const before = draft.slice(0, mentionTrigger.start);
|
||||
const after = draft.slice(mentionTrigger.start + 1 + mentionTrigger.query.length);
|
||||
const inserted = `${before}@${choice.label} `;
|
||||
setDraft(inserted + after);
|
||||
setMentionsByTopic((prev) => ({ ...prev, [topicId]: [...(prev[topicId] || []), choice] }));
|
||||
setMentionTrigger(null);
|
||||
setEmojiOpen(false);
|
||||
// Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange
|
||||
// (deferred a frame so the textarea's DOM value has actually updated to `inserted + after`
|
||||
// first), the cursor doesn't reliably land right after the just-inserted "@Name ", making it
|
||||
// awkward to keep typing; a bare focus() alone doesn't move the caret to a specific offset.
|
||||
const el = textareaRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(inserted.length, inserted.length);
|
||||
});
|
||||
};
|
||||
|
||||
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
|
||||
const insertMentionTrigger = () => {
|
||||
const el = textareaRef.current;
|
||||
const pos = el?.selectionStart ?? draft.length;
|
||||
const needsSpace = pos > 0 && !/\s/.test(draft[pos - 1] ?? ' ');
|
||||
const insertion = `${needsSpace ? ' ' : ''}@`;
|
||||
const next = draft.slice(0, pos) + insertion + draft.slice(pos);
|
||||
setDraft(next);
|
||||
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
|
||||
setEmojiOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
|
||||
});
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape' && mentionTrigger) {
|
||||
setMentionTrigger(null);
|
||||
return;
|
||||
}
|
||||
// A picked mention should delete as one atomic "@label" unit on Backspace, not character by
|
||||
// character (README §4.3 doesn't spell this out explicitly, but every real chat app treats an
|
||||
// inserted mention this way, and a user report confirmed the plain-textarea default -- delete
|
||||
// one character at a time -- read as broken here specifically). Only intercepts a plain caret
|
||||
// (no active selection) sitting right after one of THIS topic's actually-tracked mentions, so
|
||||
// an unrelated hand-typed "foo@bar" is untouched.
|
||||
if (e.key === 'Backspace' && topicId && pendingMentions.length > 0) {
|
||||
const el = e.currentTarget;
|
||||
if (el.selectionStart === el.selectionEnd) {
|
||||
const pos = el.selectionStart;
|
||||
const before = draft.slice(0, pos);
|
||||
const hit = pendingMentions.find((m) => before.endsWith(`@${m.label} `) || before.endsWith(`@${m.label}`));
|
||||
if (hit) {
|
||||
const token = before.endsWith(`@${hit.label} `) ? `@${hit.label} ` : `@${hit.label}`;
|
||||
const newBefore = before.slice(0, before.length - token.length);
|
||||
const next = newBefore + draft.slice(pos);
|
||||
e.preventDefault();
|
||||
setDraft(next);
|
||||
setMentionsByTopic((prev) => ({ ...prev, [topicId]: (prev[topicId] || []).filter((m) => m !== hit) }));
|
||||
requestAnimationFrame(() => el.setSelectionRange(newBefore.length, newBefore.length));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key !== 'Enter' || e.shiftKey) return;
|
||||
// Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit --
|
||||
// a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/
|
||||
// compositionstart/keyCode-229 guards anywhere in that codebase).
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
e.preventDefault();
|
||||
submit();
|
||||
};
|
||||
|
||||
const toggleSelect = (seq: number) => {
|
||||
setSelectedSeqs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(seq)) next.delete(seq);
|
||||
else next.add(seq);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const exitSelectMode = () => {
|
||||
setSelectMode(false);
|
||||
setSelectedSeqs(new Set());
|
||||
};
|
||||
|
||||
const bulkDelete = () => {
|
||||
if (selectedSeqs.size === 0) return;
|
||||
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
|
||||
topic.delMessagesList([...selectedSeqs], false);
|
||||
exitSelectMode();
|
||||
};
|
||||
|
||||
const bulkFavorite = () => {
|
||||
if (selectedSeqs.size === 0) return;
|
||||
// Same recalled-mid-session race as bulkForward above -- a message favorited with null content
|
||||
// would just be a useless empty entry in 我的收藏 with no way to tell what it used to say.
|
||||
const seqs = [...selectedSeqs].filter((seq) => {
|
||||
const msg = topic.findMessage(seq);
|
||||
return !msg || !isRecalled(msg);
|
||||
});
|
||||
if (seqs.length) {
|
||||
favoriteMessages(seqs.map((seq) => ({ topic: topicId, seq })))
|
||||
.then(notifyFavoriteResult)
|
||||
.catch((err) => showError(err, '收藏失败'));
|
||||
}
|
||||
exitSelectMode();
|
||||
};
|
||||
|
||||
const bulkForward = (targetTopics: string[], comment: string) => {
|
||||
// `selectedSeqs` is a Set, which iterates in INSERTION (click) order, not numeric order -- a
|
||||
// user who happened to click message #10 before #3 would otherwise forward them in that click
|
||||
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
|
||||
// actually said first.
|
||||
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
|
||||
for (const targetTopic of targetTopics) {
|
||||
// A target that isn't the conversation currently open (i.e. almost any real forward target)
|
||||
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
|
||||
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
|
||||
// edge case.
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
for (const seq of orderedSeqs) {
|
||||
const msg = topic.findMessage(seq);
|
||||
// A message can be recalled by its sender AFTER it was already selected (the checkbox
|
||||
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
|
||||
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
|
||||
// with a misleading "forwarded from" tag rather than the message the user actually picked.
|
||||
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
setBulkForwardOpen(false);
|
||||
exitSelectMode();
|
||||
};
|
||||
|
||||
const handleAction = (action: MessageAction) => {
|
||||
const msg = menu?.msg;
|
||||
setMenu(null);
|
||||
if (!msg || typeof msg.seq !== 'number') return;
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
|
||||
break;
|
||||
case 'recall':
|
||||
recallMessage(topicId, msg.seq)
|
||||
.then(() => topic.delMessagesList([msg.seq], true))
|
||||
.catch((err) => showError(err, '撤回失败'));
|
||||
break;
|
||||
case 'delete':
|
||||
topic.delMessagesList([msg.seq], false);
|
||||
break;
|
||||
case 'select':
|
||||
setSelectMode(true);
|
||||
setSelectedSeqs(new Set([msg.seq]));
|
||||
break;
|
||||
case 'quote':
|
||||
setReplyTo(msg);
|
||||
break;
|
||||
case 'favorite':
|
||||
favoriteMessages([{ topic: topicId, seq: msg.seq }])
|
||||
.then(notifyFavoriteResult)
|
||||
.catch((err) => showError(err, '收藏失败'));
|
||||
break;
|
||||
case 'forward':
|
||||
setForwardMsg(msg);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败'));
|
||||
};
|
||||
|
||||
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-surface-chat">
|
||||
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-[15px] font-medium text-text-heading">已选择 {selectedSeqs.size} 条消息</span>
|
||||
<button type="button" onClick={exitSelectMode} className="text-sm text-accent">
|
||||
取消
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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>
|
||||
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
|
||||
<span
|
||||
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
|
||||
>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<strong className="text-base font-semibold text-text-heading">
|
||||
{peerName}
|
||||
{isGroup ? <span className="ml-1 font-normal text-text-tertiary-2">({groupMemberCount})</span> : null}
|
||||
</strong>
|
||||
{isGroup ? (
|
||||
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
|
||||
) : (
|
||||
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
|
||||
{contact?.online ? '在线' : '离线'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{isGroup ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
|
||||
}}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="call" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
|
||||
}}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="videocam" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenHistory('text')}
|
||||
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
|
||||
>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDetail}
|
||||
className={`grid h-9.5 w-9.5 place-items-center rounded-lg ${detailVisible ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-1'}`}
|
||||
>
|
||||
<Icon name="info" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{isGroup && groupSummaryQuery.data?.announcement?.content ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGroupModal('announcement')}
|
||||
className="flex items-center gap-2 border-b border-[#eceef1] bg-[#fff8e6] px-5 py-2.5 text-left text-[13px] text-[#6d4c41]"
|
||||
>
|
||||
<Icon name="campaign" className="flex-none text-[18px] text-announce" />
|
||||
<strong className="flex-none font-semibold">群公告</strong>
|
||||
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.announcement.content}</span>
|
||||
<Icon name="chevron_right" className="flex-none text-[18px] text-[#c2a26c]" />
|
||||
</button>
|
||||
) : null}
|
||||
{isGroup && isGroupAdmin && !!groupSummaryQuery.data?.pending_join_request_count ? (
|
||||
<div className="flex items-center gap-2 border-b border-[#eceef1] bg-[#e8f1fe] px-5 py-2.5 text-[13px] text-[#1a4f9c]">
|
||||
<Icon name="person_add" className="flex-none text-[18px] text-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.pending_join_request_count} 条新的进群申请</span>
|
||||
<button type="button" onClick={() => setGroupModal('join-requests')} className="flex-none font-medium text-accent">
|
||||
去处理
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
|
||||
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
|
||||
{messages.map((msg) => {
|
||||
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
|
||||
return (
|
||||
<MessageRow
|
||||
key={msg.seq}
|
||||
topic={topic}
|
||||
msg={msg}
|
||||
mine={!!myUserId && msg.from === myUserId}
|
||||
isGroup={isGroup}
|
||||
peerName={peerName}
|
||||
senderName={sender?.display_name}
|
||||
senderRole={sender?.role}
|
||||
selectMode={selectMode}
|
||||
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
|
||||
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
|
||||
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
|
||||
onToggleSelect={toggleSelect}
|
||||
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
|
||||
onError={setErrorToast}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectMode ? (
|
||||
<div className="flex h-18 flex-none items-center justify-center gap-10 border-t border-border-panel bg-white">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBulkForwardOpen(true)}
|
||||
disabled={selectedSeqs.size === 0}
|
||||
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
|
||||
>
|
||||
<Icon name="reply" className="text-[22px]" />
|
||||
<span className="text-xs">转发</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={bulkFavorite}
|
||||
disabled={selectedSeqs.size === 0}
|
||||
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
|
||||
>
|
||||
<Icon name="inventory_2" className="text-[22px]" />
|
||||
<span className="text-xs">收藏</span>
|
||||
</button>
|
||||
<button type="button" onClick={bulkDelete} className="flex flex-col items-center gap-1 text-danger-text">
|
||||
<Icon name="delete_outline" className="text-[22px]" />
|
||||
<span className="text-xs">删除</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
|
||||
{replyTo ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
|
||||
<span className="truncate">
|
||||
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
|
||||
<Icon name="close" className="text-[16px]" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative flex items-center gap-0.5 px-1 pb-1">
|
||||
<div ref={emojiContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmojiOpen((v) => !v);
|
||||
setMentionTrigger(null);
|
||||
}}
|
||||
className={`grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default hover:bg-[#f2f4f7]'}`}
|
||||
>
|
||||
<Icon name="sentiment_satisfied_alt" />
|
||||
</button>
|
||||
{emojiOpen ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<EmojiPanel
|
||||
onPick={(emoji) => {
|
||||
setDraft(draft + emoji);
|
||||
setEmojiOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
|
||||
<Icon name="image" />
|
||||
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
|
||||
</label>
|
||||
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
|
||||
<Icon name="attach_file" />
|
||||
<input type="file" onChange={handlePickFile} className="hidden" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={notImplemented}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="contact_page" />
|
||||
</button>
|
||||
{isGroup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={insertMentionTrigger}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="alternate_email" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecording(true)}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="mic_none" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenHistory('text')}
|
||||
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
|
||||
>
|
||||
<Icon name="history" />
|
||||
</button>
|
||||
{mentionTrigger && isGroup ? (
|
||||
<div className="absolute bottom-full left-0 z-20 mb-2">
|
||||
<MentionPicker topicId={topicId} query={mentionTrigger.query} onPick={pickMention} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{recording ? (
|
||||
<VoiceRecorder
|
||||
onCancel={() => setRecording(false)}
|
||||
onSend={(blob, durationMs) => {
|
||||
setRecording(false);
|
||||
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* textarea + send button must share one flex row (design: 02-messages.dc.html's
|
||||
`display:flex; align-items:flex-end; gap:12px`) -- they were previously two block-level
|
||||
siblings with no shared flex parent (the actual parent only had `flex-none`, a flex-CHILD
|
||||
property that does nothing to make ITSELF a flex container), so the button fell to its
|
||||
own line below the textarea instead of sitting beside it. */}
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={handleDraftChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
|
||||
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
className="h-9 flex-none rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu ? (
|
||||
<MessageContextMenu
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
||||
recallRemainingSeconds={recallRemainingSeconds(menu.msg, myUserId, bootstrap.data?.message_policy)}
|
||||
onAction={handleAction}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{forwardMsg ? (
|
||||
<ForwardPickerModal
|
||||
onClose={() => setForwardMsg(null)}
|
||||
onPick={(targetTopics, comment) => {
|
||||
for (const targetTopic of targetTopics) {
|
||||
ensureTopicAttached(targetTopic)
|
||||
.then((target) => {
|
||||
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
|
||||
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
|
||||
})
|
||||
.catch((err) => showError(err, '转发失败'));
|
||||
}
|
||||
setForwardMsg(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
|
||||
|
||||
{groupModal === 'announcement' ? (
|
||||
<GroupAnnouncementModal topicId={topicId} canEdit={isGroupAdmin} onClose={() => setGroupModal(null)} />
|
||||
) : null}
|
||||
{groupModal === 'join-requests' ? <JoinRequestsModal topicId={topicId} onClose={() => setGroupModal(null)} /> : null}
|
||||
|
||||
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
|
||||
<Toast message={successToast} variant="success" onDone={() => setSuccessToast('')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useLayoutEffect, 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 {
|
||||
blockUser,
|
||||
deleteFriend,
|
||||
getBlocks,
|
||||
getConversationSettings,
|
||||
getFriends,
|
||||
getUserProfile,
|
||||
unblockUser,
|
||||
updateConversationSettings,
|
||||
updateFriendRemark,
|
||||
} from '@/lib/api/friends';
|
||||
import type { MediaType } from '@/lib/api/messages';
|
||||
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useCallStore } from '@/stores/calls';
|
||||
|
||||
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 friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
|
||||
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
|
||||
|
||||
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
|
||||
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
|
||||
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(topicId, remark),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
setRemarkEditing(false);
|
||||
},
|
||||
onError: (err) => showError(err, '备注保存失败'),
|
||||
});
|
||||
|
||||
const blockMutation = useMutation({
|
||||
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
|
||||
onError: (err) => showError(err, '设置失败,请重试'),
|
||||
});
|
||||
|
||||
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
|
||||
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
|
||||
|
||||
const deleteFriendMutation = useMutation({
|
||||
mutationFn: () => deleteFriend(topicId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['friends'] });
|
||||
navigate('/messages');
|
||||
},
|
||||
onError: (err) => showError(err, '删除好友失败'),
|
||||
});
|
||||
|
||||
const clearHistory = () => {
|
||||
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
|
||||
tinode.getTopic(topicId).delMessagesAll(false);
|
||||
};
|
||||
|
||||
const startDeleteFriend = () => {
|
||||
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
|
||||
deleteFriendMutation.mutate();
|
||||
};
|
||||
|
||||
const reportAbuse = () => {
|
||||
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
|
||||
};
|
||||
|
||||
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">
|
||||
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
|
||||
{avatarLetter(peerName)}
|
||||
</span>
|
||||
<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,188 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Contact } from 'tinode-sdk';
|
||||
|
||||
import { Icon } from '@/app/Icon';
|
||||
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. `forwardFavorites` (favorites.ts) already accepts
|
||||
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
|
||||
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
|
||||
* so callers loop `targetTopics` themselves -- this component only collects the selection.
|
||||
*/
|
||||
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('');
|
||||
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 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>
|
||||
<span
|
||||
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
|
||||
>
|
||||
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<span className="text-xs text-text-tertiary-2">好友</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionSelectAll(friendTopics, allFriendsSelected)}
|
||||
className="text-xs text-accent"
|
||||
>
|
||||
{allFriendsSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{friends.map(renderRow)}
|
||||
</>
|
||||
) : null}
|
||||
{groups.length ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-3 pb-1 pt-2">
|
||||
<span className="text-xs text-text-tertiary-2">群聊</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSectionSelectAll(groupTopics, allGroupsSelected)}
|
||||
className="text-xs text-accent"
|
||||
>
|
||||
{allGroupsSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
{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,393 @@
|
||||
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 { renderDrafty } from '@/lib/messages/drafty';
|
||||
import { formatEventTimestamp } from '@/lib/messages/time';
|
||||
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. */
|
||||
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
|
||||
const content = item.snapshot?.content;
|
||||
if (!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 {
|
||||
window.alert('链接安全检查失败,请稍后重试。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'block') {
|
||||
window.alert('该链接存在安全风险,已被拦截。');
|
||||
return;
|
||||
}
|
||||
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) 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) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
|
||||
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,79 @@
|
||||
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) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
|
||||
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(m.display_name)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user