('service', 'Service', errors);\n *\n * ...\n * throw error.create(Err.GENERIC);\n * ...\n * throw error.create(Err.FILE_NOT_FOUND, {'file': fileName});\n * ...\n * // Service: Could not file file: foo.txt (service/file-not-found).\n *\n * catch (e) {\n * assert(e.message === \"Could not find file: foo.txt.\");\n * if ((e as FirebaseError)?.code === 'service/file-not-found') {\n * console.log(\"Could not read file: \" + e['file']);\n * }\n * }\n */\nconst ERROR_NAME = 'FirebaseError';\n// Based on code from:\n// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error#Custom_Error_Types\nclass FirebaseError extends Error {\n constructor(\n /** The error code for this error. */\n code, message, \n /** Custom data for this error. */\n customData) {\n super(message);\n this.code = code;\n this.customData = customData;\n /** The custom name for all FirebaseErrors. */\n this.name = ERROR_NAME;\n // Fix For ES5\n // https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work\n // TODO(dlarocque): Replace this with `new.target`: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget\n // which we can now use since we no longer target ES5.\n Object.setPrototypeOf(this, FirebaseError.prototype);\n // Maintains proper stack trace for where our error was thrown.\n // Only available on V8.\n if (Error.captureStackTrace) {\n Error.captureStackTrace(this, ErrorFactory.prototype.create);\n }\n }\n}\nclass ErrorFactory {\n constructor(service, serviceName, errors) {\n this.service = service;\n this.serviceName = serviceName;\n this.errors = errors;\n }\n create(code, ...data) {\n const customData = data[0] || {};\n const fullCode = `${this.service}/${code}`;\n const template = this.errors[code];\n const message = template ? replaceTemplate(template, customData) : 'Error';\n // Service Name: Error message (service/code).\n const fullMessage = `${this.serviceName}: ${message} (${fullCode}).`;\n const error = new FirebaseError(fullCode, fullMessage, customData);\n return error;\n }\n}\nfunction replaceTemplate(template, data) {\n return template.replace(PATTERN, (_, key) => {\n const value = data[key];\n return value != null ? String(value) : `<${key}?>`;\n });\n}\nconst PATTERN = /\\{\\$([^}]+)}/g;\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Evaluates a JSON string into a javascript object.\n *\n * @param {string} str A string containing JSON.\n * @return {*} The javascript object representing the specified JSON.\n */\nfunction jsonEval(str) {\n return JSON.parse(str);\n}\n/**\n * Returns JSON representing a javascript object.\n * @param {*} data JavaScript object to be stringified.\n * @return {string} The JSON contents of the object.\n */\nfunction stringify(data) {\n return JSON.stringify(data);\n}\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Decodes a Firebase auth. token into constituent parts.\n *\n * Notes:\n * - May return with invalid / incomplete claims if there's no native base64 decoding support.\n * - Doesn't check if the token is actually valid.\n */\nconst decode = function (token) {\n let header = {}, claims = {}, data = {}, signature = '';\n try {\n const parts = token.split('.');\n header = jsonEval(base64Decode(parts[0]) || '');\n claims = jsonEval(base64Decode(parts[1]) || '');\n signature = parts[2];\n data = claims['d'] || {};\n delete claims['d'];\n }\n catch (e) { }\n return {\n header,\n claims,\n data,\n signature\n };\n};\n/**\n * Decodes a Firebase auth. token and checks the validity of its time-based claims. Will return true if the\n * token is within the time window authorized by the 'nbf' (not-before) and 'iat' (issued-at) claims.\n *\n * Notes:\n * - May return a false negative if there's no native base64 decoding support.\n * - Doesn't check if the token is actually valid.\n */\nconst isValidTimestamp = function (token) {\n const claims = decode(token).claims;\n const now = Math.floor(new Date().getTime() / 1000);\n let validSince = 0, validUntil = 0;\n if (typeof claims === 'object') {\n if (claims.hasOwnProperty('nbf')) {\n validSince = claims['nbf'];\n }\n else if (claims.hasOwnProperty('iat')) {\n validSince = claims['iat'];\n }\n if (claims.hasOwnProperty('exp')) {\n validUntil = claims['exp'];\n }\n else {\n // token will expire after 24h by default\n validUntil = validSince + 86400;\n }\n }\n return (!!now &&\n !!validSince &&\n !!validUntil &&\n now >= validSince &&\n now <= validUntil);\n};\n/**\n * Decodes a Firebase auth. token and returns its issued at time if valid, null otherwise.\n *\n * Notes:\n * - May return null if there's no native base64 decoding support.\n * - Doesn't check if the token is actually valid.\n */\nconst issuedAtTime = function (token) {\n const claims = decode(token).claims;\n if (typeof claims === 'object' && claims.hasOwnProperty('iat')) {\n return claims['iat'];\n }\n return null;\n};\n/**\n * Decodes a Firebase auth. token and checks the validity of its format. Expects a valid issued-at time.\n *\n * Notes:\n * - May return a false negative if there's no native base64 decoding support.\n * - Doesn't check if the token is actually valid.\n */\nconst isValidFormat = function (token) {\n const decoded = decode(token), claims = decoded.claims;\n return !!claims && typeof claims === 'object' && claims.hasOwnProperty('iat');\n};\n/**\n * Attempts to peer into an auth token and determine if it's an admin auth token by looking at the claims portion.\n *\n * Notes:\n * - May return a false negative if there's no native base64 decoding support.\n * - Doesn't check if the token is actually valid.\n */\nconst isAdmin = function (token) {\n const claims = decode(token).claims;\n return typeof claims === 'object' && claims['admin'] === true;\n};\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nfunction contains(obj, key) {\n return Object.prototype.hasOwnProperty.call(obj, key);\n}\nfunction safeGet(obj, key) {\n if (Object.prototype.hasOwnProperty.call(obj, key)) {\n return obj[key];\n }\n else {\n return undefined;\n }\n}\nfunction isEmpty(obj) {\n for (const key in obj) {\n if (Object.prototype.hasOwnProperty.call(obj, key)) {\n return false;\n }\n }\n return true;\n}\nfunction map(obj, fn, contextObj) {\n const res = {};\n for (const key in obj) {\n if (Object.prototype.hasOwnProperty.call(obj, key)) {\n res[key] = fn.call(contextObj, obj[key], key, obj);\n }\n }\n return res;\n}\n/**\n * Deep equal two objects. Support Arrays and Objects.\n */\nfunction deepEqual(a, b) {\n if (a === b) {\n return true;\n }\n const aKeys = Object.keys(a);\n const bKeys = Object.keys(b);\n for (const k of aKeys) {\n if (!bKeys.includes(k)) {\n return false;\n }\n const aProp = a[k];\n const bProp = b[k];\n if (isObject(aProp) && isObject(bProp)) {\n if (!deepEqual(aProp, bProp)) {\n return false;\n }\n }\n else if (aProp !== bProp) {\n return false;\n }\n }\n for (const k of bKeys) {\n if (!aKeys.includes(k)) {\n return false;\n }\n }\n return true;\n}\nfunction isObject(thing) {\n return thing !== null && typeof thing === 'object';\n}\n\n/**\n * @license\n * Copyright 2022 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Rejects if the given promise doesn't resolve in timeInMS milliseconds.\n * @internal\n */\nfunction promiseWithTimeout(promise, timeInMS = 2000) {\n const deferredPromise = new Deferred();\n setTimeout(() => deferredPromise.reject('timeout!'), timeInMS);\n promise.then(deferredPromise.resolve, deferredPromise.reject);\n return deferredPromise.promise;\n}\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Returns a querystring-formatted string (e.g. &arg=val&arg2=val2) from a\n * params object (e.g. {arg: 'val', arg2: 'val2'})\n * Note: You must prepend it with ? when adding it to a URL.\n */\nfunction querystring(querystringParams) {\n const params = [];\n for (const [key, value] of Object.entries(querystringParams)) {\n if (Array.isArray(value)) {\n value.forEach(arrayVal => {\n params.push(encodeURIComponent(key) + '=' + encodeURIComponent(arrayVal));\n });\n }\n else {\n params.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));\n }\n }\n return params.length ? '&' + params.join('&') : '';\n}\n/**\n * Decodes a querystring (e.g. ?arg=val&arg2=val2) into a params object\n * (e.g. {arg: 'val', arg2: 'val2'})\n */\nfunction querystringDecode(querystring) {\n const obj = {};\n const tokens = querystring.replace(/^\\?/, '').split('&');\n tokens.forEach(token => {\n if (token) {\n const [key, value] = token.split('=');\n obj[decodeURIComponent(key)] = decodeURIComponent(value);\n }\n });\n return obj;\n}\n/**\n * Extract the query string part of a URL, including the leading question mark (if present).\n */\nfunction extractQuerystring(url) {\n const queryStart = url.indexOf('?');\n if (!queryStart) {\n return '';\n }\n const fragmentStart = url.indexOf('#', queryStart);\n return url.substring(queryStart, fragmentStart > 0 ? fragmentStart : undefined);\n}\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * @fileoverview SHA-1 cryptographic hash.\n * Variable names follow the notation in FIPS PUB 180-3:\n * http://csrc.nist.gov/publications/fips/fips180-3/fips180-3_final.pdf.\n *\n * Usage:\n * var sha1 = new sha1();\n * sha1.update(bytes);\n * var hash = sha1.digest();\n *\n * Performance:\n * Chrome 23: ~400 Mbit/s\n * Firefox 16: ~250 Mbit/s\n *\n */\n/**\n * SHA-1 cryptographic hash constructor.\n *\n * The properties declared here are discussed in the above algorithm document.\n * @constructor\n * @final\n * @struct\n */\nclass Sha1 {\n constructor() {\n /**\n * Holds the previous values of accumulated variables a-e in the compress_\n * function.\n * @private\n */\n this.chain_ = [];\n /**\n * A buffer holding the partially computed hash result.\n * @private\n */\n this.buf_ = [];\n /**\n * An array of 80 bytes, each a part of the message to be hashed. Referred to\n * as the message schedule in the docs.\n * @private\n */\n this.W_ = [];\n /**\n * Contains data needed to pad messages less than 64 bytes.\n * @private\n */\n this.pad_ = [];\n /**\n * @private {number}\n */\n this.inbuf_ = 0;\n /**\n * @private {number}\n */\n this.total_ = 0;\n this.blockSize = 512 / 8;\n this.pad_[0] = 128;\n for (let i = 1; i < this.blockSize; ++i) {\n this.pad_[i] = 0;\n }\n this.reset();\n }\n reset() {\n this.chain_[0] = 0x67452301;\n this.chain_[1] = 0xefcdab89;\n this.chain_[2] = 0x98badcfe;\n this.chain_[3] = 0x10325476;\n this.chain_[4] = 0xc3d2e1f0;\n this.inbuf_ = 0;\n this.total_ = 0;\n }\n /**\n * Internal compress helper function.\n * @param buf Block to compress.\n * @param offset Offset of the block in the buffer.\n * @private\n */\n compress_(buf, offset) {\n if (!offset) {\n offset = 0;\n }\n const W = this.W_;\n // get 16 big endian words\n if (typeof buf === 'string') {\n for (let i = 0; i < 16; i++) {\n // TODO(user): [bug 8140122] Recent versions of Safari for Mac OS and iOS\n // have a bug that turns the post-increment ++ operator into pre-increment\n // during JIT compilation. We have code that depends heavily on SHA-1 for\n // correctness and which is affected by this bug, so I've removed all uses\n // of post-increment ++ in which the result value is used. We can revert\n // this change once the Safari bug\n // (https://bugs.webkit.org/show_bug.cgi?id=109036) has been fixed and\n // most clients have been updated.\n W[i] =\n (buf.charCodeAt(offset) << 24) |\n (buf.charCodeAt(offset + 1) << 16) |\n (buf.charCodeAt(offset + 2) << 8) |\n buf.charCodeAt(offset + 3);\n offset += 4;\n }\n }\n else {\n for (let i = 0; i < 16; i++) {\n W[i] =\n (buf[offset] << 24) |\n (buf[offset + 1] << 16) |\n (buf[offset + 2] << 8) |\n buf[offset + 3];\n offset += 4;\n }\n }\n // expand to 80 words\n for (let i = 16; i < 80; i++) {\n const t = W[i - 3] ^ W[i - 8] ^ W[i - 14] ^ W[i - 16];\n W[i] = ((t << 1) | (t >>> 31)) & 0xffffffff;\n }\n let a = this.chain_[0];\n let b = this.chain_[1];\n let c = this.chain_[2];\n let d = this.chain_[3];\n let e = this.chain_[4];\n let f, k;\n // TODO(user): Try to unroll this loop to speed up the computation.\n for (let i = 0; i < 80; i++) {\n if (i < 40) {\n if (i < 20) {\n f = d ^ (b & (c ^ d));\n k = 0x5a827999;\n }\n else {\n f = b ^ c ^ d;\n k = 0x6ed9eba1;\n }\n }\n else {\n if (i < 60) {\n f = (b & c) | (d & (b | c));\n k = 0x8f1bbcdc;\n }\n else {\n f = b ^ c ^ d;\n k = 0xca62c1d6;\n }\n }\n const t = (((a << 5) | (a >>> 27)) + f + e + k + W[i]) & 0xffffffff;\n e = d;\n d = c;\n c = ((b << 30) | (b >>> 2)) & 0xffffffff;\n b = a;\n a = t;\n }\n this.chain_[0] = (this.chain_[0] + a) & 0xffffffff;\n this.chain_[1] = (this.chain_[1] + b) & 0xffffffff;\n this.chain_[2] = (this.chain_[2] + c) & 0xffffffff;\n this.chain_[3] = (this.chain_[3] + d) & 0xffffffff;\n this.chain_[4] = (this.chain_[4] + e) & 0xffffffff;\n }\n update(bytes, length) {\n // TODO(johnlenz): tighten the function signature and remove this check\n if (bytes == null) {\n return;\n }\n if (length === undefined) {\n length = bytes.length;\n }\n const lengthMinusBlock = length - this.blockSize;\n let n = 0;\n // Using local instead of member variables gives ~5% speedup on Firefox 16.\n const buf = this.buf_;\n let inbuf = this.inbuf_;\n // The outer while loop should execute at most twice.\n while (n < length) {\n // When we have no data in the block to top up, we can directly process the\n // input buffer (assuming it contains sufficient data). This gives ~25%\n // speedup on Chrome 23 and ~15% speedup on Firefox 16, but requires that\n // the data is provided in large chunks (or in multiples of 64 bytes).\n if (inbuf === 0) {\n while (n <= lengthMinusBlock) {\n this.compress_(bytes, n);\n n += this.blockSize;\n }\n }\n if (typeof bytes === 'string') {\n while (n < length) {\n buf[inbuf] = bytes.charCodeAt(n);\n ++inbuf;\n ++n;\n if (inbuf === this.blockSize) {\n this.compress_(buf);\n inbuf = 0;\n // Jump to the outer loop so we use the full-block optimization.\n break;\n }\n }\n }\n else {\n while (n < length) {\n buf[inbuf] = bytes[n];\n ++inbuf;\n ++n;\n if (inbuf === this.blockSize) {\n this.compress_(buf);\n inbuf = 0;\n // Jump to the outer loop so we use the full-block optimization.\n break;\n }\n }\n }\n }\n this.inbuf_ = inbuf;\n this.total_ += length;\n }\n /** @override */\n digest() {\n const digest = [];\n let totalBits = this.total_ * 8;\n // Add pad 0x80 0x00*.\n if (this.inbuf_ < 56) {\n this.update(this.pad_, 56 - this.inbuf_);\n }\n else {\n this.update(this.pad_, this.blockSize - (this.inbuf_ - 56));\n }\n // Add # bits.\n for (let i = this.blockSize - 1; i >= 56; i--) {\n this.buf_[i] = totalBits & 255;\n totalBits /= 256; // Don't use bit-shifting here!\n }\n this.compress_(this.buf_);\n let n = 0;\n for (let i = 0; i < 5; i++) {\n for (let j = 24; j >= 0; j -= 8) {\n digest[n] = (this.chain_[i] >> j) & 255;\n ++n;\n }\n }\n return digest;\n }\n}\n\n/**\n * Helper to make a Subscribe function (just like Promise helps make a\n * Thenable).\n *\n * @param executor Function which can make calls to a single Observer\n * as a proxy.\n * @param onNoObservers Callback when count of Observers goes to zero.\n */\nfunction createSubscribe(executor, onNoObservers) {\n const proxy = new ObserverProxy(executor, onNoObservers);\n return proxy.subscribe.bind(proxy);\n}\n/**\n * Implement fan-out for any number of Observers attached via a subscribe\n * function.\n */\nclass ObserverProxy {\n /**\n * @param executor Function which can make calls to a single Observer\n * as a proxy.\n * @param onNoObservers Callback when count of Observers goes to zero.\n */\n constructor(executor, onNoObservers) {\n this.observers = [];\n this.unsubscribes = [];\n this.observerCount = 0;\n // Micro-task scheduling by calling task.then().\n this.task = Promise.resolve();\n this.finalized = false;\n this.onNoObservers = onNoObservers;\n // Call the executor asynchronously so subscribers that are called\n // synchronously after the creation of the subscribe function\n // can still receive the very first value generated in the executor.\n this.task\n .then(() => {\n executor(this);\n })\n .catch(e => {\n this.error(e);\n });\n }\n next(value) {\n this.forEachObserver((observer) => {\n observer.next(value);\n });\n }\n error(error) {\n this.forEachObserver((observer) => {\n observer.error(error);\n });\n this.close(error);\n }\n complete() {\n this.forEachObserver((observer) => {\n observer.complete();\n });\n this.close();\n }\n /**\n * Subscribe function that can be used to add an Observer to the fan-out list.\n *\n * - We require that no event is sent to a subscriber synchronously to their\n * call to subscribe().\n */\n subscribe(nextOrObserver, error, complete) {\n let observer;\n if (nextOrObserver === undefined &&\n error === undefined &&\n complete === undefined) {\n throw new Error('Missing Observer.');\n }\n // Assemble an Observer object when passed as callback functions.\n if (implementsAnyMethods(nextOrObserver, [\n 'next',\n 'error',\n 'complete'\n ])) {\n observer = nextOrObserver;\n }\n else {\n observer = {\n next: nextOrObserver,\n error,\n complete\n };\n }\n if (observer.next === undefined) {\n observer.next = noop;\n }\n if (observer.error === undefined) {\n observer.error = noop;\n }\n if (observer.complete === undefined) {\n observer.complete = noop;\n }\n const unsub = this.unsubscribeOne.bind(this, this.observers.length);\n // Attempt to subscribe to a terminated Observable - we\n // just respond to the Observer with the final error or complete\n // event.\n if (this.finalized) {\n // eslint-disable-next-line @typescript-eslint/no-floating-promises\n this.task.then(() => {\n try {\n if (this.finalError) {\n observer.error(this.finalError);\n }\n else {\n observer.complete();\n }\n }\n catch (e) {\n // nothing\n }\n return;\n });\n }\n this.observers.push(observer);\n return unsub;\n }\n // Unsubscribe is synchronous - we guarantee that no events are sent to\n // any unsubscribed Observer.\n unsubscribeOne(i) {\n if (this.observers === undefined || this.observers[i] === undefined) {\n return;\n }\n delete this.observers[i];\n this.observerCount -= 1;\n if (this.observerCount === 0 && this.onNoObservers !== undefined) {\n this.onNoObservers(this);\n }\n }\n forEachObserver(fn) {\n if (this.finalized) {\n // Already closed by previous event....just eat the additional values.\n return;\n }\n // Since sendOne calls asynchronously - there is no chance that\n // this.observers will become undefined.\n for (let i = 0; i < this.observers.length; i++) {\n this.sendOne(i, fn);\n }\n }\n // Call the Observer via one of it's callback function. We are careful to\n // confirm that the observe has not been unsubscribed since this asynchronous\n // function had been queued.\n sendOne(i, fn) {\n // Execute the callback asynchronously\n // eslint-disable-next-line @typescript-eslint/no-floating-promises\n this.task.then(() => {\n if (this.observers !== undefined && this.observers[i] !== undefined) {\n try {\n fn(this.observers[i]);\n }\n catch (e) {\n // Ignore exceptions raised in Observers or missing methods of an\n // Observer.\n // Log error to console. b/31404806\n if (typeof console !== 'undefined' && console.error) {\n console.error(e);\n }\n }\n }\n });\n }\n close(err) {\n if (this.finalized) {\n return;\n }\n this.finalized = true;\n if (err !== undefined) {\n this.finalError = err;\n }\n // Proxy is no longer needed - garbage collect references\n // eslint-disable-next-line @typescript-eslint/no-floating-promises\n this.task.then(() => {\n this.observers = undefined;\n this.onNoObservers = undefined;\n });\n }\n}\n/** Turn synchronous function into one called asynchronously. */\n// eslint-disable-next-line @typescript-eslint/ban-types\nfunction async(fn, onError) {\n return (...args) => {\n Promise.resolve(true)\n .then(() => {\n fn(...args);\n })\n .catch((error) => {\n if (onError) {\n onError(error);\n }\n });\n };\n}\n/**\n * Return true if the object passed in implements any of the named methods.\n */\nfunction implementsAnyMethods(obj, methods) {\n if (typeof obj !== 'object' || obj === null) {\n return false;\n }\n for (const method of methods) {\n if (method in obj && typeof obj[method] === 'function') {\n return true;\n }\n }\n return false;\n}\nfunction noop() {\n // do nothing\n}\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Check to make sure the appropriate number of arguments are provided for a public function.\n * Throws an error if it fails.\n *\n * @param fnName The function name\n * @param minCount The minimum number of arguments to allow for the function call\n * @param maxCount The maximum number of argument to allow for the function call\n * @param argCount The actual number of arguments provided.\n */\nconst validateArgCount = function (fnName, minCount, maxCount, argCount) {\n let argError;\n if (argCount < minCount) {\n argError = 'at least ' + minCount;\n }\n else if (argCount > maxCount) {\n argError = maxCount === 0 ? 'none' : 'no more than ' + maxCount;\n }\n if (argError) {\n const error = fnName +\n ' failed: Was called with ' +\n argCount +\n (argCount === 1 ? ' argument.' : ' arguments.') +\n ' Expects ' +\n argError +\n '.';\n throw new Error(error);\n }\n};\n/**\n * Generates a string to prefix an error message about failed argument validation\n *\n * @param fnName The function name\n * @param argName The name of the argument\n * @return The prefix to add to the error thrown for validation.\n */\nfunction errorPrefix(fnName, argName) {\n return `${fnName} failed: ${argName} argument `;\n}\n/**\n * @param fnName\n * @param argumentNumber\n * @param namespace\n * @param optional\n */\nfunction validateNamespace(fnName, namespace, optional) {\n if (optional && !namespace) {\n return;\n }\n if (typeof namespace !== 'string') {\n //TODO: I should do more validation here. We only allow certain chars in namespaces.\n throw new Error(errorPrefix(fnName, 'namespace') + 'must be a valid firebase namespace.');\n }\n}\nfunction validateCallback(fnName, argumentName, \n// eslint-disable-next-line @typescript-eslint/ban-types\ncallback, optional) {\n if (optional && !callback) {\n return;\n }\n if (typeof callback !== 'function') {\n throw new Error(errorPrefix(fnName, argumentName) + 'must be a valid function.');\n }\n}\nfunction validateContextObject(fnName, argumentName, context, optional) {\n if (optional && !context) {\n return;\n }\n if (typeof context !== 'object' || context === null) {\n throw new Error(errorPrefix(fnName, argumentName) + 'must be a valid context object.');\n }\n}\n\n/**\n * @license\n * Copyright 2017 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n// Code originally came from goog.crypt.stringToUtf8ByteArray, but for some reason they\n// automatically replaced '\\r\\n' with '\\n', and they didn't handle surrogate pairs,\n// so it's been modified.\n// Note that not all Unicode characters appear as single characters in JavaScript strings.\n// fromCharCode returns the UTF-16 encoding of a character - so some Unicode characters\n// use 2 characters in JavaScript. All 4-byte UTF-8 characters begin with a first\n// character in the range 0xD800 - 0xDBFF (the first character of a so-called surrogate\n// pair).\n// See http://www.ecma-international.org/ecma-262/5.1/#sec-15.1.3\n/**\n * @param {string} str\n * @return {Array}\n */\nconst stringToByteArray = function (str) {\n const out = [];\n let p = 0;\n for (let i = 0; i < str.length; i++) {\n let c = str.charCodeAt(i);\n // Is this the lead surrogate in a surrogate pair?\n if (c >= 0xd800 && c <= 0xdbff) {\n const high = c - 0xd800; // the high 10 bits.\n i++;\n assert(i < str.length, 'Surrogate pair missing trail surrogate.');\n const low = str.charCodeAt(i) - 0xdc00; // the low 10 bits.\n c = 0x10000 + (high << 10) + low;\n }\n if (c < 128) {\n out[p++] = c;\n }\n else if (c < 2048) {\n out[p++] = (c >> 6) | 192;\n out[p++] = (c & 63) | 128;\n }\n else if (c < 65536) {\n out[p++] = (c >> 12) | 224;\n out[p++] = ((c >> 6) & 63) | 128;\n out[p++] = (c & 63) | 128;\n }\n else {\n out[p++] = (c >> 18) | 240;\n out[p++] = ((c >> 12) & 63) | 128;\n out[p++] = ((c >> 6) & 63) | 128;\n out[p++] = (c & 63) | 128;\n }\n }\n return out;\n};\n/**\n * Calculate length without actually converting; useful for doing cheaper validation.\n * @param {string} str\n * @return {number}\n */\nconst stringLength = function (str) {\n let p = 0;\n for (let i = 0; i < str.length; i++) {\n const c = str.charCodeAt(i);\n if (c < 128) {\n p++;\n }\n else if (c < 2048) {\n p += 2;\n }\n else if (c >= 0xd800 && c <= 0xdbff) {\n // Lead surrogate of a surrogate pair. The pair together will take 4 bytes to represent.\n p += 4;\n i++; // skip trail surrogate.\n }\n else {\n p += 3;\n }\n }\n return p;\n};\n\n/**\n * @license\n * Copyright 2019 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * The amount of milliseconds to exponentially increase.\n */\nconst DEFAULT_INTERVAL_MILLIS = 1000;\n/**\n * The factor to backoff by.\n * Should be a number greater than 1.\n */\nconst DEFAULT_BACKOFF_FACTOR = 2;\n/**\n * The maximum milliseconds to increase to.\n *\n * Visible for testing\n */\nconst MAX_VALUE_MILLIS = 4 * 60 * 60 * 1000; // Four hours, like iOS and Android.\n/**\n * The percentage of backoff time to randomize by.\n * See\n * http://go/safe-client-behavior#step-1-determine-the-appropriate-retry-interval-to-handle-spike-traffic\n * for context.\n *\n *
Visible for testing\n */\nconst RANDOM_FACTOR = 0.5;\n/**\n * Based on the backoff method from\n * https://github.com/google/closure-library/blob/master/closure/goog/math/exponentialbackoff.js.\n * Extracted here so we don't need to pass metadata and a stateful ExponentialBackoff object around.\n */\nfunction calculateBackoffMillis(backoffCount, intervalMillis = DEFAULT_INTERVAL_MILLIS, backoffFactor = DEFAULT_BACKOFF_FACTOR) {\n // Calculates an exponentially increasing value.\n // Deviation: calculates value from count and a constant interval, so we only need to save value\n // and count to restore state.\n const currBaseValue = intervalMillis * Math.pow(backoffFactor, backoffCount);\n // A random \"fuzz\" to avoid waves of retries.\n // Deviation: randomFactor is required.\n const randomWait = Math.round(\n // A fraction of the backoff value to add/subtract.\n // Deviation: changes multiplication order to improve readability.\n RANDOM_FACTOR *\n currBaseValue *\n // A random float (rounded to int by Math.round above) in the range [-1, 1]. Determines\n // if we add or subtract.\n (Math.random() - 0.5) *\n 2);\n // Limits backoff to max to avoid effectively permanent backoff.\n return Math.min(MAX_VALUE_MILLIS, currBaseValue + randomWait);\n}\n\n/**\n * @license\n * Copyright 2020 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Provide English ordinal letters after a number\n */\nfunction ordinal(i) {\n if (!Number.isFinite(i)) {\n return `${i}`;\n }\n return i + indicator(i);\n}\nfunction indicator(i) {\n i = Math.abs(i);\n const cent = i % 100;\n if (cent >= 10 && cent <= 20) {\n return 'th';\n }\n const dec = i % 10;\n if (dec === 1) {\n return 'st';\n }\n if (dec === 2) {\n return 'nd';\n }\n if (dec === 3) {\n return 'rd';\n }\n return 'th';\n}\n\n/**\n * @license\n * Copyright 2021 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nfunction getModularInstance(service) {\n if (service && service._delegate) {\n return service._delegate;\n }\n else {\n return service;\n }\n}\n\n/**\n * @license\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * Checks whether host is a cloud workstation or not.\n * @public\n */\nfunction isCloudWorkstation(url) {\n // `isCloudWorkstation` is called without protocol in certain connect*Emulator functions\n // In HTTP request builders, it's called with the protocol.\n // If called with protocol prefix, it's a valid URL, so we extract the hostname\n // If called without, we assume the string is the hostname.\n try {\n const host = url.startsWith('http://') || url.startsWith('https://')\n ? new URL(url).hostname\n : url;\n return host.endsWith('.cloudworkstations.dev');\n }\n catch {\n return false;\n }\n}\n/**\n * Makes a fetch request to the given server.\n * Mostly used for forwarding cookies in Firebase Studio.\n * @public\n */\nasync function pingServer(endpoint) {\n const result = await fetch(endpoint, {\n credentials: 'include'\n });\n return result.ok;\n}\n\n/**\n * @license\n * Copyright 2025 Google LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n/**\n * @public\n * Generates a SHA-256 hash for the given input string.\n *\n * @param input The string to hash.\n * @returns A promise that resolves to the SHA-256 hash as a hex string.\n */\nasync function generateSHA256Hash(input) {\n const textEncoder = new TextEncoder();\n const data = textEncoder.encode(input);\n const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n // Convert ArrayBuffer to hex string\n const hashArray = Array.from(new Uint8Array(hashBuffer));\n const hexHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n return hexHash;\n}\n\nexport { CONSTANTS, DecodeBase64StringError, Deferred, ErrorFactory, FirebaseError, MAX_VALUE_MILLIS, RANDOM_FACTOR, Sha1, areCookiesEnabled, assert, assertionError, async, base64, base64Decode, base64Encode, base64urlEncodeWithoutPadding, calculateBackoffMillis, contains, createMockUserToken, createSubscribe, decode, deepCopy, deepEqual, deepExtend, errorPrefix, extractQuerystring, generateSHA256Hash, getDefaultAppConfig, getDefaultEmulatorHost, getDefaultEmulatorHostnameAndPort, getDefaults, getExperimentalSetting, getGlobal, getModularInstance, getUA, isAdmin, isBrowser, isBrowserExtension, isCloudWorkstation, isCloudflareWorker, isElectron, isEmpty, isIE, isIndexedDBAvailable, isMobileCordova, isNode, isNodeSdk, isReactNative, isSafari, isSafariOrWebkit, isUWP, isValidFormat, isValidTimestamp, isWebWorker, issuedAtTime, jsonEval, map, ordinal, pingServer, promiseWithTimeout, querystring, querystringDecode, safeGet, stringLength, stringToByteArray, stringify, validateArgCount, validateCallback, validateContextObject, validateIndexedDBOpenable, validateNamespace };\n//# sourceMappingURL=index.esm.js.map\n","import { PACKAGE_VERSION } from './version.js';\n\n// Version-independent name of this application, used for Firebase app name.\nexport const BASE_APP_NAME = 'TinodeWeb';\n// Name of this application, used in the User-Agent.\nexport const APP_NAME = BASE_APP_NAME + '/' + (PACKAGE_VERSION || '0.25');\n\n// API key. Use https://github.com/tinode/chat/tree/master/keygen to generate your own.\n// Injected at build time via webpack DefinePlugin from the TINODE_API_KEY env var\n// (see webpack.config.js and .env.local). Falls back to the public sample key when unset,\n// which only works against a backend still using the matching sample API_KEY_SALT.\nexport const API_KEY = process.env.TINODE_API_KEY || 'AQEAAAABAAD_rAp4DJh05a1HAwFT3A6K';\n\n// The array of possible hosts to connect to.\nexport const KNOWN_HOSTS = {hosted: 'web.tinode.co', local: 'localhost:6060'};\n\n// Default host name and port to connect to.\nexport const DEFAULT_HOST = KNOWN_HOSTS.hosted;\n\n// Enable console logging.\nexport const LOGGING_ENABLED = true;\n\n// Minimum time between two keypress notifications, milliseconds.\nexport const KEYPRESS_DELAY = 3_000;\n// Delay before sending a read notification, milliseconds.\nexport const READ_DELAY = 1_500;\n\n// The default shortest allowed tag length. Matches the value on the server.\nexport const MIN_TAG_LENGTH = 2;\n// The default greatest allowed tag length. Matches the value on the server.\nexport const MAX_TAG_LENGTH = 96;\n// The default maximum number of tags allowed. Matches the value on the server.\nexport const MAX_TAG_COUNT = 16;\n\n// Access mode for P2P subscriptions initiated by the current user.\nexport const DEFAULT_P2P_ACCESS_MODE = 'JRWPS';\n// Access mode for new group topics created by the current user.\nexport const NEW_GRP_ACCESS_MODE = 'JRWPSAO';\n// Access mode for a channel.\nexport const CHANNEL_ACCESS_MODE = 'JR';\n\n// Access mode for no access.\nexport const NO_ACCESS_MODE = 'N';\n\n// Mediaquery breakpoint between desktop and mobile, in px. Should match the value\n// in @media (max-size: 640px) in base.css\nexport const MEDIA_BREAKPOINT = 640;\n// Size of css 'rem' unit in pixels. Default 1rem = 10pt = 13px.\nexport const REM_SIZE = 13;\n\n// Size of the avatar image: when image dimensions are greater or the image is not square, it's resized to\n// a square of this size or less.\nexport const AVATAR_SIZE = 384;\n\n// Maximum size of an avatar in bytes for sending in-band. Bigger avatars will be sent out of band (as uploads).\nexport const MAX_AVATAR_BYTES = 4096;\n\n// Size of the broken_image shown in MessagesView\nexport const BROKEN_IMAGE_SIZE = 32;\n\n// Size of video preview when dimensions are missing.\nexport const NO_DIMENSIONS_VIDEO = 128;\n\n// Number of chat messages to fetch in one call.\nexport const MESSAGES_PAGE = 24;\n\n// Default maximum in-band (included directly into the message) attachment size which fits into\n// a message of 256K in size. Used when the server-provided value is unavailable. The actual\n// binary size of the attachment should be smaller due to base64 encoding expansion and some overhead,\n// for instance 1024 bytes.\n// Increase this limit to a greater value in production, if desired. Also increase\n// max_message_size in the server config.\nexport const MAX_INBAND_ATTACHMENT_SIZE = 262_144;\n\n// Default absolute maximum attachment size to be used with the server = 8MB.\n// Used when the server-provided value is unavailable. Increase to something like 100MB in production.\nexport const MAX_EXTERN_ATTACHMENT_SIZE = 1 << 23;\n\n// Maximum allowed linear dimension (pixels) of an image sent inline. Larger images will be shrunk\n// to make the larger dimension fit under this size. You may want to adjust it to 1600 or\n// 2400 for production.\nexport const MAX_IMAGE_DIM = 1024;\n\n// Linear dimensions of image preview: shrink image under this size for previews.\nexport const IMAGE_PREVIEW_DIM = 64;\n\n// Linear dimensions of video screencapture: shrink image under this size for previews.\nexport const VIDEO_PREVIEW_DIM = 96;\n\n// Linear dimensions of image thumbnail: shrink image under this size for thumbnails in reply quote previews.\nexport const IMAGE_THUMBNAIL_DIM = 36;\n// Width of video thumbnail: allow it to be wider, but not taller. Use IMAGE_THUMBNAIL_DIM for height.\nexport const VIDEO_THUMBNAIL_WIDTH = 48;\n\n// Maximum number of online users to be shown in a topic title bar. Others will be\n// hidden under \"+X more\"\nexport const MAX_ONLINE_IN_TOPIC = 4;\n\n// Maximum length of user name, topic title, and private comment.\nexport const MAX_TITLE_LENGTH = 60;\n// Maximum length of topic description.\nexport const MAX_TOPIC_DESCRIPTION_LENGTH = 360;\n// Maximum length of user name when displaying a video call.\nexport const MAX_PEER_TITLE_LENGTH = 24;\n\n// Length of message previews in chat list, in characters.\nexport const MESSAGE_PREVIEW_LENGTH = 80;\n\n// Length of a quote in quoted reply (for outgoing messages).\nexport const QUOTED_REPLY_LENGTH = 30;\n// Length of a preview of a forwarded message.\nexport const FORWARDED_PREVIEW_LENGTH = 84;\n// Length of preview for a message being edited.\nexport const EDIT_PREVIEW_LENGTH = 48;\n\n// Minimum duration of an audio recording in milliseconds (2 sec).\nexport const MIN_DURATION = 2_000;\n// Maximum duration of an audio recording in milliseconds (10 min).\nexport const MAX_DURATION = 600_000;\n\n// Link for \"Contact Us\".\nexport const LINK_CONTACT_US = 'mailto:support@tinode.co';\n\n// Link to Privacy Policy.\nexport const LINK_PRIVACY_POLICY = 'https://tinode.co/privacy.html';\n\n// Link to Terms of Service.\nexport const LINK_TERMS_OF_SERVICE = 'https://tinode.co/terms.html';\n\n// When user starts a P2P topic, automatic subscription does not\n// happen until the first message. Setting this option to true enables\n// immediate subscription.\nexport const IMMEDIATE_P2P_SUBSCRIPTION = false;\n\n// URL schemes permitted to appear in links (i.e. no javascript: scheme).\nexport const CLICKABLE_URL_SCHEMES = ['http', 'https', 'ftp', 'ftps'];\n\n// Size of the QR code in the app.\nexport const QRCODE_SIZE = 128;\n\n// How much time has to pass to consider computer awoken from sleep (80 sec).\n// Chrome throttles ticker events in inactive tabs to once per minute. This\n// value ensures that these once-a-minute events do not trigger a reconnect.\nexport const WAKE_UP_TIMEOUT = 80000;\n// Timer interval between checks if computer woke up from sleep (1 sec).\nexport const WAKE_UP_TICK = 1000;\n\n// Swipe distance to register as a gesture, in pixels.\nexport const MIN_SWIPE_DISTANCE = REM_SIZE * 3;\n\n// Self-topic avatar as an SVG icon encoded as data URI\nexport const SELF_AVATAR_URI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48c3ZnIHZpZXdCb3g9IjAgMCAxNyAxNyIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMy41NjIgMXYxNS40NTlsNC42ODYtMy4yNyA0Ljc1MiAzLjI2di0xNS40NDloLTkuNDM4ek0xMiAxNC41NTFsLTMuNzU2LTIuNTc4LTMuNjgxIDIuNTY4di0xMi41NDFoNy40Mzd2MTIuNTUxeiIgZmlsbD0iIzU1NTU1NSIgLz48L3N2Zz4=' // eslint-disable-line max-len\n\n// Toast display duration, milliseconds.\nexport const TOAST_DURATION = 3_000;\n\n// Initial/default color scheme.\nexport const DEFAULT_COLOR_SCHEME = 'auto';\n\n// Default text size in points.\nexport const DEFAULT_TEXT_SIZE = 10;\n\n// MIME type for the legacy Drafty form-response data.\nexport const DRAFTY_FR_MIME_TYPE_LEGACY = 'application/json'; // Remove in 2026.\n\n// Path to wallpaper images.\nexport const WALLPAPER_DEFAULTS = {type: 'patt', index: 0, path:'img/bkg/'};\n","// Video call states.\nexport const CALL_STATE_NONE = 0;\nexport const CALL_STATE_OUTGOING_INITATED = 1;\nexport const CALL_STATE_INCOMING_RECEIVED = 2;\nexport const CALL_STATE_IN_PROGRESS = 3;\n\n// Video call message headers representing call state.\nexport const CALL_HEAD_STARTED = 'started';\n\n","// File and image helper functions.\nimport { TheCard } from 'tinode-sdk';\n\n// Supported image MIME types and corresponding file extensions.\nexport const SUPPORTED_IMAGE_FORMATS = ['image/jpeg', 'image/gif', 'image/png', 'image/svg', 'image/svg+xml'];\nexport const MIME_EXTENSIONS = ['jpg', 'gif', 'png', 'svg', 'svg'];\n\n// Get an URL from a theCard photo: either create a data: URL or return reference URL.\nexport function makeImageUrl(photo) {\n if (photo && typeof photo == 'object') {\n if (photo.ref) {\n return photo.ref\n }\n if (photo.data && photo.type) {\n const mime = photo.type.startsWith('image/') ? photo.type : ('image/' + photo.type);\n return 'data:' + mime + ';base64,' + photo.data;\n }\n }\n return null;\n}\n\n// Calculate linear dimensions for scaling image down to fit under a certain size.\n// Returns an object which contains destination sizes, source sizes, and offsets\n// into source (when making square images).\nexport function fitImageSize(width, height, maxWidth, maxHeight, forceSquare) {\n // Sanitize input\n width = width | 0;\n height = height | 0;\n maxWidth = maxWidth | 0;\n maxHeight = maxHeight | 0;\n\n if (width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0) {\n return null;\n }\n\n if (forceSquare) {\n maxWidth = maxHeight = Math.min(maxWidth, maxHeight);\n }\n\n const scale = Math.min(\n Math.min(width, maxWidth) / width,\n Math.min(height, maxHeight) / height\n );\n\n const size = {\n dstWidth: (width * scale) | 0,\n dstHeight: (height * scale) | 0,\n };\n\n if (forceSquare) {\n // Also calculate parameters for making the image square.\n size.dstWidth = size.dstHeight = Math.min(size.dstWidth, size.dstHeight);\n size.srcWidth = size.srcHeight = Math.min(width, height);\n size.xoffset = ((width - size.srcWidth) / 2) | 0;\n size.yoffset = ((height - size.srcWidth) / 2) | 0;\n } else {\n size.xoffset = size.yoffset = 0;\n size.srcWidth = width;\n size.srcHeight = height;\n }\n return size;\n}\n\n// Ensure file's extension matches mime content type\nexport function fileNameForMime(fname, mime) {\n const idx = SUPPORTED_IMAGE_FORMATS.indexOf(mime);\n if (idx < 0 || !fname) {\n // Unknown mime or empty name.\n return fname;\n }\n const ext = MIME_EXTENSIONS[idx];\n\n const at = fname.lastIndexOf('.');\n if (at >= 0) {\n fname = fname.substring(0, at);\n }\n return fname + '.' + ext;\n}\n\n// Scale uploaded image to fit under certain dimensions and byte size, optionally constraining to a square.\n// Returns a promise which is resolven on success or rejected on failure.\nexport function imageScaled(fileOrBlob, maxWidth, maxHeight, maxSize, forceSquare) {\n return new Promise((resolve, reject) => {\n const img = new Image();\n img.crossOrigin = 'Anonymous';\n img.onerror = function(err) {\n reject(new Error(\"Image format unrecognized\"));\n }\n img.onload = async function() {\n // Once the image is loaded, the URL is no longer needed.\n URL.revokeObjectURL(img.src);\n\n // Calculate the desired image dimensions.\n const dim = fitImageSize(img.width, img.height, maxWidth, maxHeight, forceSquare);\n if (!dim) {\n reject(new Error(\"Invalid image\"));\n return;\n }\n let canvas = document.createElement('canvas');\n canvas.width = dim.dstWidth;\n canvas.height = dim.dstHeight;\n let ctx = canvas.getContext('2d');\n ctx.imageSmoothingEnabled = true;\n ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,\n 0, 0, dim.dstWidth, dim.dstHeight);\n\n const mime = SUPPORTED_IMAGE_FORMATS.includes(fileOrBlob.type) ? fileOrBlob.type : 'image/jpeg';\n // Generate blob to check size of the image.\n let blob = await new Promise(resolve => canvas.toBlob(resolve, mime));\n if (!blob) {\n reject(new Error(\"Unsupported image format\"));\n return;\n }\n // Ensure the image is not too large. Shrink the image keeping the aspect ratio.\n // Do nothing if maxsize is <= 0.\n while (maxSize > 0 && blob.length > maxSize) {\n dim.dstWidth = (dim.dstWidth * 0.70710678118) | 0;\n dim.dstHeight = (dim.dstHeight * 0.70710678118) | 0;\n canvas.width = dim.dstWidth;\n canvas.height = dim.dstHeight;\n ctx = canvas.getContext('2d');\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.drawImage(img, dim.xoffset, dim.yoffset, dim.srcWidth, dim.srcHeight,\n 0, 0, dim.dstWidth, dim.dstHeight);\n blob = await new Promise(resolve => canvas.toBlob(resolve, mime));\n }\n\n canvas = null;\n resolve({mime: mime, blob: blob, width: dim.dstWidth, height: dim.dstHeight, name: fileNameForMime(fileOrBlob.name, mime)});\n };\n img.src = URL.createObjectURL(fileOrBlob);\n });\n}\n\n// Scale and crop image according to specified dimensions.\n// The coordinates are in unscaled image pixels, i.e. cut the rectangle first then scale it.\n// returns a promise.\nexport function imageCrop(mime, objURL, left, top, width, height, scale) {\n return new Promise((resolve, reject) => {\n const img = new Image();\n img.crossOrigin = 'Anonymous';\n img.onerror = _ => {\n reject(new Error(\"Image format unrecognized\"));\n };\n img.onload = _ => {\n // Once the image is loaded, the URL is no longer needed.\n URL.revokeObjectURL(img.src);\n\n let canvas = document.createElement('canvas');\n canvas.width = width * scale;\n canvas.height = height * scale;\n let ctx = canvas.getContext('2d');\n ctx.imageSmoothingEnabled = true;\n ctx.drawImage(img, left, top, width, height, 0, 0, canvas.width, canvas.height);\n\n mime = SUPPORTED_IMAGE_FORMATS.includes(mime) ? mime : 'image/jpeg';\n // Generate blob to check size of the image.\n canvas.toBlob((blob) => {\n // Allow GC.\n canvas = null;\n if (blob) {\n resolve({mime: mime, blob: blob, width: width, height: height});\n } else {\n reject(new Error(\"Unsupported image format\"));\n }\n }, mime);\n };\n img.src = objURL;\n });\n}\n\n// Convert file to base64 string.\nexport function fileToBase64(file) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onerror = _ => {\n reject(reader.error);\n };\n reader.onload = _ => {\n resolve({mime: file.type, bits: reader.result.split(',')[1], name: file.name});\n };\n reader.readAsDataURL(file);\n });\n}\n\n// Convert Blob to base64 string. Returns a promise resolved with the base64 string and mime.\nexport function blobToBase64(blob) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onerror = _ => {\n reject(reader.error);\n };\n reader.onload = _ => {\n resolve({mime: blob.type, bits: reader.result.split(',')[1]});\n };\n reader.readAsDataURL(blob);\n });\n}\n\n// File pasted from the clipboard. It's either an inline image or a file attachment.\nexport function filePasted(event, onImageSuccess, onAttachmentSuccess, onError) {\n const items = (event.clipboardData || event.originalEvent.clipboardData || {}).items;\n if (!items || !items.length) {\n return false;\n }\n\n for (let i in items) {\n const item = items[i];\n if (item.kind === 'file') {\n const file = item.getAsFile();\n if (!file) {\n console.error(\"Failed to get file object from pasted file item\", item.kind, item.type);\n onError(\"Failed to get file object from pasted file item\");\n continue;\n }\n if (file.type && file.type.split('/')[0] == 'image') {\n onImageSuccess(file);\n } else {\n onAttachmentSuccess(file);\n }\n // Indicate that the pasted data contains a file.\n return true;\n }\n }\n // No file found.\n return false;\n}\n\n// Get mime type from data URL header.\nexport function getMimeType(header) {\n const mime = /^data:(image\\/[-+a-z0-9.]+);base64/.exec(header);\n return (mime && mime.length > 1) ? mime[1] : null;\n}\n\n// Given length of a binary object in bytes, calculate the length after\n// base64 encoding.\nexport function base64EncodedLen(n) {\n return Math.floor((n + 2) / 3) * 4;\n}\n\n// Given length of a base64-encoded object, calculate decoded size of the\n// pbject in bytes.\nexport function base64DecodedLen(n) {\n return Math.floor(n / 4) * 3;\n}\n\n// Re-encode string to standard base64 encoding with padding.\n// The string may be base64-URL encoded without the padding.\nexport function base64ReEncode(str) {\n if (str) {\n str = str.replace(/-/g, '+').replace(/_/g, '/');\n try {\n str = btoa(atob(str));\n } catch(err) {\n console.error(\"Failed to base64 re-encode string.\", err);\n str = null;\n }\n }\n return str;\n}\n\n// Convert a base64 encoded string with the provided mime type into a Blob.\nexport function base64ToBlob(str, mime) {\n if (!str) {\n return null;\n }\n\n try {\n // Make blob.\n const bin = atob(str);\n const length = bin.length;\n const buf = new ArrayBuffer(length);\n const arr = new Uint8Array(buf);\n for (let i = 0; i < length; i++) {\n arr[i] = bin.charCodeAt(i);\n }\n\n return new Blob([buf], { type: mime });\n } catch(err) {\n console.error(\"Failed to convert base64 to blob: \", err);\n }\n\n return null;\n}\n\nexport function intArrayToBase64(arr) {\n if (!Array.isArray(arr)) {\n return null;\n }\n try {\n let bin = '';\n new Uint8Array(arr).forEach(b => bin += String.fromCharCode(b));\n return window.btoa(bin);\n } catch (err) {}\n return null;\n}\n\nexport function base64ToIntArray(b64) {\n const arr = [];\n try {\n const bin = window.atob(b64);\n [...bin].forEach(c => {\n arr.push(c.charCodeAt(0));\n });\n return arr;\n } catch (err) {}\n return null;\n}\n\nexport function importVCard(fileOrBlob) {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onerror = _ => {\n reject(reader.error);\n };\n reader.onload = _ => {\n const card = TheCard.importVCard(reader.result);\n if (!card) {\n reject(new Error(\"Failed to parse vCard\"));\n return;\n }\n resolve(card);\n };\n reader.readAsText(fileOrBlob);\n });\n}\n","import React from 'react';\nimport { defineMessages } from 'react-intl';\n\nimport { Drafty, TheCard } from 'tinode-sdk';\n\nimport AudioPlayer from '../widgets/audio-player.jsx'\nimport CallMessage from '../widgets/call-message.jsx'\nimport CallStatus from '../widgets/call-status.jsx';\nimport InlineVideo from '../widgets/inline-video.jsx';\nimport LazyImage from '../widgets/lazy-image.jsx';\nimport TheCardMini from '../widgets/the-card-mini.jsx';\nimport UploadingImage from '../widgets/uploading-image.jsx'\n\nimport { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO,\n REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js';\nimport { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js';\nimport { idToColorClass, secondsToTime, shortenFileName } from './strformat.js';\nimport { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js';\n\nconst messages = defineMessages({\n drafty_form: {\n id: 'drafty_form',\n defaultMessage: 'Form: ',\n description: 'Comment for form in Drafty'\n },\n drafty_attachment: {\n id: 'drafty_attachment',\n defaultMessage: 'Attachment',\n description: 'Comment for attachment in Drafty'\n },\n drafty_image: {\n id: 'drafty_image',\n defaultMessage: 'Picture',\n description: 'Comment for embedded images in Drafty'\n },\n drafty_video: {\n id: 'drafty_video',\n defaultMessage: 'Video recording',\n description: 'Comment for videos embedded in Drafty'\n },\n drafty_unknown: {\n id: 'drafty_unknown',\n defaultMessage: 'Unsupported',\n description: 'Unsupported entity in drafty'\n }\n});\n\n// The main Drafty formatter: converts Drafty elements into React classes. 'this' is set by the caller.\n// 'this' must contain:\n// viewportWidth:\n// authorizeURL:\n// onHandleClick:\nexport function fullFormatter(style, data, values, key, stack) {\n if (stack.includes('QQ')) {\n return quoteFormatter.call(this, style, data, values, key);\n }\n\n if (!style) {\n // Unformatted.\n return values;\n }\n\n let el = Drafty.tagName(style);\n let attr = Drafty.attrValue(style, data) || {};\n attr.key = key;\n switch (style) {\n case 'AU':\n // Show audio player.\n if (attr.src) {\n attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio'));\n attr.duration = data.duration > 0 ? (data.duration | 0) : undefined;\n attr.preview = data.preview;\n attr.loading = 'lazy';\n }\n el = AudioPlayer;\n // Audio element cannot have content.\n values = null;\n break;\n case 'BR':\n values = null;\n break;\n case 'EX':\n // Ignore.\n break;\n case 'HL':\n // Highlighted text. Assign class name.\n attr.className = 'highlight';\n break;\n case 'HD':\n el = null;\n values = null;\n break;\n case 'IM':\n // Additional processing for images\n el = handleImageData.call(this, el, data, attr);\n // Image element cannot have content.\n values = null;\n break;\n case 'BN':\n // Button\n attr.onClick = (e) => this.onHandleClick(e, 'form_button');\n let inner = React.Children.map(values, (child) => {\n return typeof child == 'string' ? child : undefined;\n });\n if (!inner || inner.length == 0) {\n inner = [attr.name]\n }\n // Get text which will be sent back when the button is clicked.\n attr['data-title'] = inner.join('');\n break;\n case 'LN':\n if (attr) {\n // Ensure only safe URL schemes are clickable.\n attr.href = typeof attr.href == 'string' ? sanitizeUrl(attr.href, CLICKABLE_URL_SCHEMES) : '';\n }\n break;\n case 'MN':\n // Mention\n attr.className = 'mention'\n if (data) {\n attr.className += ' ' + idToColorClass(data.val, false, true);\n }\n break;\n case 'FM':\n // Form\n attr.className = 'bot-form';\n break;\n case 'RW':\n // Form element formatting is dependent on element content.\n break;\n case 'QQ':\n // Quote/citation.\n attr.className = 'reply-quote'\n attr.onClick = (e) => this.onHandleClick(e, 'quote');\n break;\n case 'TC':\n // TheCard\n el = TheCardMini;\n attr.content = data;\n attr.authorizeURL = this.authorizeURL;\n attr.onChatClick = (e) => this.onHandleClick(e, 'contact_chat');\n attr.onFindClick = (e) => this.onHandleClick(e, 'contact_find');\n values = null;\n break;\n case 'VC':\n el = CallMessage;\n // Video call messages do not have content.\n values = null;\n if (data) {\n attr.callState = data.state;\n attr.incoming = data.incoming;\n attr.duration = data.duration;\n }\n break;\n case 'VD':\n // Additional processing for videos.\n el = handleVideoData.call(this, el, data, attr);\n // Video element cannot have content.\n values = null;\n break;\n default:\n if (!el) {\n // Unknown element.\n el = React.Fragment;\n attr = {key: key};\n // Generate comment for unknown element.\n let body = values;\n if (!Array.isArray(values) || !values.join('').trim()) {\n body = [React.createElement('span',\n {key: 'x1', className: 'gray'},\n this.formatMessage(messages.drafty_unknown)\n )];\n }\n values = [React.createElement('i',\n {key: 'x0', className: 'material-icons gray'},\n 'extension'\n ), ' '].concat(body);\n }\n break;\n }\n if (!el) {\n return values;\n }\n return React.createElement(el, attr, values);\n}\n\n// Additional processing of image data.\nfunction handleImageData(el, data, attr) {\n if (!data) {\n attr.src = 'img/broken_image.png';\n attr.style = {\n width: IMAGE_THUMBNAIL_DIM + 'px',\n height: IMAGE_THUMBNAIL_DIM + 'px',\n };\n return el;\n }\n\n attr.className = 'inline-image';\n const dim = fitImageSize(data.width, data.height,\n this.viewportWidth > 0 ? Math.min(this.viewportWidth - REM_SIZE * 6.5, REM_SIZE * 34.5) :\n REM_SIZE * 34.5, REM_SIZE * 24, false) ||\n {dstWidth: BROKEN_IMAGE_SIZE, dstHeight: BROKEN_IMAGE_SIZE};\n attr.style = {\n width: dim.dstWidth + 'px',\n height: dim.dstHeight + 'px',\n // Looks like a Chrome bug: broken image does not respect 'width' and 'height'.\n minWidth: dim.dstWidth + 'px',\n minHeight: dim.dstHeight + 'px'\n };\n if (!Drafty.isProcessing(data)) {\n attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image'));\n attr.alt = data.name;\n if (attr.src) {\n if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) {\n // Allow previews for large enough images.\n attr.onClick = (e) => this.onHandleClick(e, 'image');\n attr.className += ' image-clickable';\n }\n attr.loading = 'lazy';\n } else {\n attr.src = null;\n }\n } else {\n // Use custom element instead of
or
; }\nfunction SystemEvent({children}) { return {children}
; }\nfunction GroupChat({mode, onToast}) {\n const [joined, setJoined] = React.useState(false);\n return {['group-new-request', 'group-summary'].includes(mode) ?
: null}{['group-announcement', 'group-summary'].includes(mode) ?
: null}
今天 10:24
{mode === 'group-invite' ? 一起参与新版产品的设计与研发讨论
: <>安妮邀请栗子来了加入群聊管理员通过了王珊珊的进群申请>} ;\n}\n\nfunction JoinRequests({topic, onToast}) {\n // A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)\n // is a second entry point besides group-settings, and this whole feature remounts on every\n // navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working\n // through requests re-triggers this blank \"加载中\" flash on essentially every accept/reject.\n const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);\n const [profiles, setProfiles] = React.useState({});\n const [loadError, setLoadError] = React.useState(false);\n // Tracked in a ref (not state) so `load` doesn't need `profiles` in its dependency array --\n // depending on `profiles` here would make `load` a new function every time a profile fetch\n // resolves, which would re-trigger the `useEffect(() => load(), [load])` below and re-fetch\n // the whole join-request list in a cascade, not just skip already-known profiles.\n const fetchedIds = React.useRef(new Set());\n const load = React.useCallback(() => {\n if (!topic) return;\n getGroupJoinRequests(topic, 'all').then(data => {\n const list = data.items || [];\n writeCachedData('group-join-requests:' + topic, list);\n setItems(list);\n const ids = new Set();\n list.forEach(r => { ids.add(r.user_id); if (r.inviter_id) ids.add(r.inviter_id); });\n ids.forEach(id => {\n if (fetchedIds.current.has(id)) return;\n fetchedIds.current.add(id);\n getUserProfile(id).then(p => setProfiles(v => ({...v, [id]: p}))).catch(() => { fetchedIds.current.delete(id); });\n });\n }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n React.useEffect(() => { setItems((topic && readCachedData('group-join-requests:' + topic)) || null); load(); }, [topic, load]);\n const handle = (req, accept) => {\n const call = accept ? acceptGroupJoinRequest(topic, req.request_id, req.version) : rejectGroupJoinRequest(topic, req.request_id, req.version);\n call.then(() => { onToast(accept ? '已同意进群申请' : '已拒绝进群申请'); load(); })\n .catch(err => onToast(err.message || '操作失败'));\n };\n const nameOf = id => (profiles[id] && profiles[id].display_name) || id;\n const describe = req => req.source === 'qr' ? `${nameOf(req.user_id)}通过二维码分享进群` :\n req.inviter_id ? `${nameOf(req.inviter_id)}邀请${nameOf(req.user_id)}进群` : `${nameOf(req.user_id)}申请加入群聊`;\n // Exact resolved/rejected label wording isn't confirmed against the live status enum yet -- falls back\n // to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.\n const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};\n return \n
\n
\n {items === null ? {loadError ? '申请列表加载失败' : '加载中…'}
:\n items.length ? items.map(item =>\n \n \n {describe(item)}{shortDate(item.created_at)}
\n {item.status === 'pending' ?\n \n \n \n :\n {statusLabel[item.status] || item.status}}\n ) :\n 暂无进群申请
}\n \n
;\n}\n\nfunction Announcement({topic, myRole, fromChat, onToast}) {\n // Shares the 'group-announcement:'+topic cache key with GroupSettings' preview snippet below --\n // whichever screen loads first warms the cache for the other, and both are re-entered often (the\n // in-chat banner is a persistent second entry point on top of group-settings).\n const cachedAnnouncement = topic && readCachedData('group-announcement:' + topic);\n const [data, setData] = React.useState(() => cachedAnnouncement || null);\n const [draft, setDraft] = React.useState(() => (cachedAnnouncement && cachedAnnouncement.content) || '');\n const [editing, setEditing] = React.useState(false);\n const [saving, setSaving] = React.useState(false);\n const [loadError, setLoadError] = React.useState(false);\n const canEdit = myRole === 'owner' || myRole === 'admin';\n // Coming from the announcement banner atop the chat itself (messages-view.jsx's\n // handleShowAnnouncement) vs. from group-settings' \"群公告\" row land on the identical\n // path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so\n // \"back\" can return to the chat (close the sidepanel) instead of always forcing group-settings.\n const backView = fromChat ? '' : 'group-settings';\n React.useEffect(() => {\n if (!topic) return;\n const cached = readCachedData('group-announcement:' + topic);\n if (cached) { setData(cached); setDraft(cached.content || ''); }\n getLatestGroupAnnouncement(topic).then(d => {\n writeCachedData('group-announcement:' + topic, d);\n setData(d); setDraft(d.content || '');\n }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n const publish = () => {\n if (!draft.trim() || saving) return;\n setSaving(true);\n updateCurrentGroupAnnouncement(topic, draft.trim(), data ? data.version : 0).then(d => {\n writeCachedData('group-announcement:' + topic, d);\n setData(d); setEditing(false); onToast('群公告已发布');\n }).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));\n };\n if (!data) return {loadError ? '群公告加载失败' : '加载中…'}
;\n return \n
\n
\n {canEdit ? \n
;\n}\n\nfunction GroupSettings({topic, myUserId, tinode, onToast}) {\n // This is the landing point for \"back\" from GroupProfile/GroupFiles/Governance/MyNicknameEditor/\n // Announcement, and (per GroupManagementView's `key={view}` note) the whole subtree remounts on\n // every one of those round trips -- essentially every group-settings visit after the first refetches\n // from scratch, making this the single highest-traffic page in this file to seed from cache.\n const [group, setGroup] = React.useState(() => (topic && readCachedData('group-settings:' + topic)) || null);\n const [loadError, setLoadError] = React.useState(false);\n // The member strip and AdminList/`myRole` all call the identical unfiltered getGroupMembers(topic) --\n // sharing one 'group-members:'+topic cache entry (storing the raw list, sliced to 9 only for local\n // display here) lets any one of the three warm it for the others.\n const [members, setMembers] = React.useState(() => {\n const cached = topic && readCachedData('group-members:' + topic);\n return cached ? cached.slice(0, 9) : [];\n });\n const [announcement, setAnnouncement] = React.useState(() => {\n const cached = topic && readCachedData('group-announcement:' + topic);\n return cached && cached.content ? cached : null;\n });\n React.useEffect(() => {\n if (!topic) return;\n setGroup(readCachedData('group-settings:' + topic) || null);\n setMembers((readCachedData('group-members:' + topic) || []).slice(0, 9));\n const cachedAnn = readCachedData('group-announcement:' + topic);\n setAnnouncement(cachedAnn && cachedAnn.content ? cachedAnn : null);\n getGroupSettings(topic).then(g => { writeCachedData('group-settings:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });\n getGroupMembers(topic).then(data => {\n const items = data.items || [];\n writeCachedData('group-members:' + topic, items);\n setMembers(items.slice(0, 9));\n }).catch(() => {});\n getLatestGroupAnnouncement(topic).then(d => {\n writeCachedData('group-announcement:' + topic, d);\n setAnnouncement(d && d.content ? d : null);\n }).catch(() => {});\n }, [topic]);\n const setPref = patch => {\n updateGroupMemberSettings(topic, {pinned: group.pinned, mute_notifications: group.mute_notifications, ...patch})\n .then(() => setGroup(g => {\n const next = {...g, ...patch};\n writeCachedData('group-settings:' + topic, next);\n return next;\n })).catch(err => onToast(err.message || '设置失败'));\n };\n const leave = () => {\n if (group && group.my_role === 'owner') { onToast('群主需先转让群主或解散群聊'); return; }\n leaveGroup(topic).then(() => { onToast('已退出群聊'); navigate('group-list'); }).catch(err => onToast(err.message || '操作失败'));\n };\n if (!group) return {loadError ? '群组信息加载失败' : '加载中…'}
;\n const canManage = group.permissions && group.permissions.can_manage_settings;\n const links = [\n ['group-profile', '群资料', group.name],\n ['group-announcement-detail', '群公告', announcement ? announcement.content : '暂无群公告'],\n ['group-files', '群文件', ''],\n ['group-my-nickname', '我在本群的昵称', '']\n ];\n if (canManage) links.splice(3, 0, ['group-governance', '群组管理', '']);\n return ;\n}\n\nfunction GroupProfile({topic, onToast}) {\n // Shares 'group:'+topic with the nickname/description editors' own getGroup(topic) calls -- opening\n // this once warms the cache for either of those too.\n const [group, setGroup] = React.useState(() => (topic && readCachedData('group:' + topic)) || null);\n const [loadError, setLoadError] = React.useState(false);\n React.useEffect(() => {\n if (!topic) return;\n setGroup(readCachedData('group:' + topic) || null);\n getGroup(topic).then(g => { writeCachedData('group:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n if (!group) return {loadError ? '群组信息加载失败' : '加载中…'}
;\n const canEdit = group.permissions && group.permissions.can_edit_profile;\n return \n
\n
\n \n {canEdit ? :\n 群昵称{group.name}
}\n {canEdit ? :\n 群简介{group.description || '群主很懒,还没有群简介'}
}\n \n {group.member_count} 位成员
\n \n
;\n}\n\nfunction AvatarSheet({topic, tinode, onToast}) {\n const inputRef = React.useRef(null);\n const [uploading, setUploading] = React.useState(false);\n const pick = () => inputRef.current && inputRef.current.click();\n const onFile = e => {\n const file = e.target.files && e.target.files[0];\n e.target.value = '';\n if (!file || !tinode) return;\n setUploading(true);\n // Fetch the current version right before the write, same optimistic-concurrency protection the\n // name/description editors already have (GroupProfile below re-fetches independently and its\n // possibly-stale copy isn't reused here) -- avatar updates were the one group-profile field with\n // none at all.\n uploadLargeFile(tinode, file)\n .then(({fileId}) => getGroup(topic).then(group => updateGroup(topic, {avatar_file_id: fileId, version: group.version})))\n .then(() => { onToast('群头像已更新'); navigate('group-profile', topic); })\n .catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));\n };\n return \n
\n
\n
\n 设置群头像
\n \n \n
\n
;\n}\n\nfunction GroupQr({topic, onToast}) {\n const ref = React.useRef(null);\n const [data, setData] = React.useState(null);\n React.useEffect(() => {\n if (!topic) return;\n createGroupQRCode(topic).then(setData).catch(() => onToast('二维码生成失败'));\n }, [topic]);\n React.useEffect(() => {\n if (!ref.current || !data) return;\n ref.current.innerHTML = '';\n new QRCode(ref.current, {text: data.qr_payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});\n }, [data]);\n const group = data && data.group_snapshot;\n const share = () => {\n if (navigator.share && data) {\n navigator.share({title: group && group.name, text: '扫码加入群聊', url: data.qr_payload}).catch(() => {});\n } else if (data) {\n navigator.clipboard.writeText(data.qr_payload).then(() => onToast('链接已复制')).catch(() => onToast('分享失败'));\n }\n };\n const save = () => {\n const canvas = ref.current && ref.current.querySelector('canvas');\n if (!canvas) { onToast('保存失败'); return; }\n const a = document.createElement('a');\n a.href = canvas.toDataURL('image/png');\n a.download = (group ? group.name : 'group') + '-qrcode.png';\n a.click();\n onToast('群二维码已保存');\n };\n return \n
\n
\n \n {group ? group.name : ''}{group ? group.member_count : 0} 位成员 \n \n 使用APP扫码二维码,加入群聊
\n \n \n \n \n
\n \n
;\n}\n\n// The five backend-persisted governance fields (server/im_http.go imGroupGovernancePatchRequest) --\n// docs/images/群组管理.png showed a 4th toggle \"群成员修改昵称\" that doesn't correspond to any real\n// governance field (its own description text also doesn't match member_upload_files' actual meaning,\n// which a *different*, buggier draft screenshot happened to label correctly). Shipping a toggle with\n// nothing to persist would be worse than a label mismatch, so this uses the mock's original 5-key\n// structure -- which lines up exactly with the 5 real backend fields -- with corrected descriptions.\nconst GOVERNANCE_FIELDS = [\n ['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'],\n ['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'],\n ['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'],\n ['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'],\n ['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']\n];\n\nfunction Governance({topic, tinode, onToast}) {\n // Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's\n // `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks\n // and returns, so this is one of the highest-traffic pages in the file.\n const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);\n const [dissolving, setDissolving] = React.useState(false);\n const [loadError, setLoadError] = React.useState(false);\n React.useEffect(() => {\n if (!topic) return;\n setGov(readCachedData('group-governance:' + topic) || null);\n getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n const setField = (key, value) => {\n updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })\n .catch(err => onToast(err.message || '设置失败'));\n };\n const dissolve = () => {\n if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;\n const t = tinode ? tinode.getTopic(topic) : null;\n if (!t) return;\n setDissolving(true);\n t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })\n .catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));\n };\n if (!gov) return {loadError ? '群组管理加载失败' : '加载中…'}
;\n return \n
\n
\n \n \n \n \n \n \n \n {GOVERNANCE_FIELDS.map(([key, label, desc]) => \n {label}{desc}\n setField(key, v)} />\n
)}\n \n \n
;\n}\n\nfunction deriveOwnerAndAdmins(items) {\n return {owner: items.find(m => m.role === 'owner') || null, admins: items.filter(m => m.role === 'admin')};\n}\n\nfunction AdminList({topic, onToast}) {\n // Shares the same 'group-members:'+topic cache entry as GroupSettings' member strip and\n // GroupManagementView's `myRole` lookup -- all three call the identical unfiltered getGroupMembers.\n const cachedMembers = topic && readCachedData('group-members:' + topic);\n const seed = cachedMembers ? deriveOwnerAndAdmins(cachedMembers) : {owner: null, admins: null};\n const [owner, setOwner] = React.useState(() => seed.owner);\n const [admins, setAdmins] = React.useState(() => seed.admins);\n const [toast, setToast] = React.useState('');\n const [loadError, setLoadError] = React.useState(false);\n const load = React.useCallback(() => {\n if (!topic) return;\n const cached = readCachedData('group-members:' + topic);\n if (cached) { const d = deriveOwnerAndAdmins(cached); setOwner(d.owner); setAdmins(d.admins); }\n // Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup\n // for the owner -- GET /users/{id}/profile 400s (\"malformed request\", field \"user_id\") when {id} is\n // the caller's own uid, which broke this whenever the viewer is themselves the group's owner.\n getGroupMembers(topic).then(data => {\n const items = data.items || [];\n writeCachedData('group-members:' + topic, items);\n const d = deriveOwnerAndAdmins(items);\n setOwner(d.owner);\n setAdmins(d.admins);\n }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n React.useEffect(() => { load(); }, [load]);\n const remove = userId => {\n deleteGroupAdmin(topic, userId).then(() => { setToast('管理员已移除'); load(); })\n .catch(err => onToast(err.message || '操作失败'));\n };\n return ;\n}\n\n// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,\n// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to\n// actually be the mute-add variant of the identical layout).\nfunction MemberPicker({type, topic, onToast}) {\n const mute = type === 'mute-add';\n const back = mute ? 'group-muted' : 'group-admins';\n const [members, setMembers] = React.useState(null);\n const [selected, setSelected] = React.useState([]);\n const [query, setQuery] = React.useState('');\n const [toast, setToast] = React.useState('');\n const [busy, setBusy] = React.useState(false);\n const [loadError, setLoadError] = React.useState(false);\n React.useEffect(() => {\n if (!topic) return;\n getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})\n .then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))\n .catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic, mute]);\n const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));\n const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));\n const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));\n const done = () => {\n if (!selected.length || busy) return;\n setBusy(true);\n const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);\n call.then(result => {\n const failed = result && result.succeeded ? result.succeeded.filter(s => !s.ok) : [];\n setToast(failed.length ? `成功设置 ${selected.length - failed.length} 人,${failed.length} 人设置失败` : '设置成功');\n window.setTimeout(() => navigate(back, topic), 900);\n }).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));\n };\n return \n
\n
\n
\n {members === null ? {loadError ? '成员列表加载失败' : '加载中…'}
: groups.map(([letter, list]) => \n {letter}
\n {list.map(item => )}\n )}\n \n
setToast('')} />\n ;\n}\n\nfunction Muted({topic, onToast}) {\n const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);\n // groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --\n // confirmed against the real backend struct), so without this it rendered raw ids like \"usr8x2kf9\"\n // instead of names. The full member roster already carries group_nickname/display_name for\n // everyone still in the group, and fetching it here is one extra request for the whole list\n // (not per-row), unlike JoinRequests' per-item getUserProfile lookup.\n const [names, setNames] = React.useState({});\n const [loadError, setLoadError] = React.useState(false);\n const load = React.useCallback(() => {\n if (!topic) return;\n getGroupMutes(topic).then(data => {\n const list = data.items || [];\n writeCachedData('group-muted:' + topic, list);\n setItems(list);\n }).catch(() => { onToast('加载失败'); setLoadError(true); });\n getGroupMembers(topic).then(data => {\n const map = {};\n (data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });\n setNames(map);\n }).catch(() => {});\n }, [topic]);\n React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);\n const unmute = userId => {\n unmuteGroupMember(topic, userId).then(() => { onToast('已解除禁言'); load(); })\n .catch(err => onToast(err.message || '操作失败'));\n };\n return ;\n}\n\nfunction OwnerSelect({topic, onToast}) {\n const [members, setMembers] = React.useState(null);\n const [query, setQuery] = React.useState('');\n const [target, setTarget] = React.useState(null);\n const [busy, setBusy] = React.useState(false);\n const [loadError, setLoadError] = React.useState(false);\n React.useEffect(() => {\n if (!topic) return;\n getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))\n .catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic]);\n const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));\n const admins = visible.filter(m => m.role === 'admin');\n const others = visible.filter(m => m.role !== 'admin');\n const confirm = () => {\n if (busy || !target) return;\n setBusy(true);\n transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })\n .catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));\n };\n const row = item => setTarget(item)}>\n {item.group_nickname || item.display_name}\n ;\n return \n
\n
\n
\n {members === null ? {loadError ? '成员列表加载失败' : '加载中…'}
: <>\n {admins.length ? <>管理员
{admins.map(row)}> : null}\n {others.length ? <>群成员
{others.map(row)}> : null}\n >}\n \n {target ?
\n 选择新群主
\n {target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份
\n \n \n : null}\n
;\n}\n\nconst FILE_TYPE_META = {\n image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'],\n document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']\n};\n\nfunction GroupFiles({topic, tinode, onToast}) {\n const [query, setQuery] = React.useState('');\n // Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a\n // plain revisit never shows a stale search result under a fresh empty query.\n const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);\n const [menuOpen, setMenuOpen] = React.useState(false);\n const [uploading, setUploading] = React.useState(null);\n const [loadError, setLoadError] = React.useState(false);\n const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};\n const load = React.useCallback(() => {\n if (!topic) return;\n getGroupFiles(topic, {query}).then(data => {\n const items = data.items || [];\n if (!query) writeCachedData('group-files:' + topic, items);\n setFiles(items);\n }).catch(() => { onToast('加载失败'); setLoadError(true); });\n }, [topic, query]);\n React.useEffect(() => { load(); }, [load]);\n const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);\n const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };\n const onFile = e => {\n const file = e.target.files && e.target.files[0];\n e.target.value = '';\n if (!file || !tinode) return;\n setUploading({name: file.name, progress: 0});\n uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))\n .then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))\n .then(() => { setUploading(null); load(); })\n .catch(err => { setUploading(null); onToast(err.message || '上传失败'); });\n };\n const remove = fileId => {\n deleteGroupFile(topic, fileId).then(() => load()).catch(err => onToast(err.message || '删除失败'));\n };\n const gb = n => (n / (1024 ** 3)).toFixed(2);\n return ;\n}\n\nfunction bytesToGB(bytes) {\n if (!bytes) return '0 KB';\n if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';\n return (bytes / (1024 * 1024)).toFixed(1) + ' MB';\n}\n\nfunction GroupNameEditor({topic, onToast}) {\n const [group, setGroup] = React.useState(null);\n const [value, setValue] = React.useState('');\n React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.name); }).catch(() => onToast('加载失败')); }, [topic]);\n const save = () => {\n if (!value.trim()) return;\n updateGroup(topic, {name: value.trim(), version: group ? group.version : 0}).then(() => {\n onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n return \n \n setValue(e.target.value)} />{value.length}/11\n
;\n}\n\nfunction GroupDescriptionEditor({topic, onToast}) {\n const [group, setGroup] = React.useState(null);\n const [value, setValue] = React.useState('');\n React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.description || ''); }).catch(() => onToast('加载失败')); }, [topic]);\n const publish = () => {\n updateGroup(topic, {description: value.trim(), version: group ? group.version : 0}).then(() => {\n onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n return \n \n \n \n
;\n}\n\n// \"我在本群的昵称\" has no dedicated reference screenshot (see plan notes) -- placed in GroupSettings,\n// governed by the real member_invite... no, by the group_nickname field on groupMemberPayload, gated by\n// the \"群成员修改昵称\" concept the images described (server-side enforcement, not re-checked client-side).\nfunction MyNicknameEditor({topic, myUserId, onToast}) {\n const [value, setValue] = React.useState('');\n React.useEffect(() => {\n if (!topic || !myUserId) return;\n getGroupMembers(topic, {query: ''}).then(data => {\n const me = (data.items || []).find(m => m.user_id === myUserId);\n if (me) setValue(me.group_nickname || '');\n }).catch(() => {});\n }, [topic, myUserId]);\n const save = () => {\n updateMyGroupProfile(topic, value.trim(), 0).then(() => {\n onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n return \n \n setValue(e.target.value)} />群昵称仅在当前群内显示。\n
;\n}\n\nexport default function GroupManagementView({view, hashParams, myUserId, tinode, onTopicSelected}) {\n const [toast, setToast] = React.useState('');\n const topic = hashParams && hashParams.topic;\n // This container remounts on every navigation step inside the feature (see `key={view}` in\n // sidepanel-view.jsx), and Announcement's edit/publish UI is gated on this role -- uncached, that\n // gate visibly flickers off and back on every time the announcement page (or any sibling page) is\n // opened. Also warms the same shared 'group-members:'+topic entry GroupSettings/AdminList use.\n const roleCacheKey = topic && myUserId ? `group-my-role:${topic}:${myUserId}` : null;\n const [myRole, setMyRole] = React.useState(() => (roleCacheKey && readCachedData(roleCacheKey)) || null);\n React.useEffect(() => {\n if (!topic || !myUserId) { setMyRole(null); return; }\n const key = `group-my-role:${topic}:${myUserId}`;\n setMyRole(readCachedData(key) || null);\n getGroupMembers(topic).then(data => {\n const items = data.items || [];\n writeCachedData('group-members:' + topic, items);\n const me = items.find(m => m.user_id === myUserId);\n const role = me ? me.role : null;\n writeCachedData(key, role);\n setMyRole(role);\n }).catch(() => {});\n }, [topic, myUserId]);\n let content;\n if (view === 'group-list' || view === 'group-empty') content = ;\n else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = ;\n else if (view === 'group-join-requests') content = ;\n else if (view === 'group-announcement-detail') content = ;\n else if (view === 'group-settings') content = ;\n else if (view === 'group-profile') content = ;\n else if (view === 'group-avatar') content = ;\n else if (view === 'group-nickname') content = ;\n else if (view === 'group-description') content = ;\n else if (view === 'group-my-nickname') content = ;\n else if (view === 'group-qr') content = ;\n else if (view === 'group-governance') content = ;\n else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = ;\n else if (view === 'group-admin-add') content = ;\n else if (view === 'group-mute-add') content = ;\n else if (view === 'group-muted') content = ;\n else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = ;\n else content = ;\n return <>{content} setToast('')} />>;\n}\n","// Login form.\nimport React from 'react';\nimport { FormattedMessage } from 'react-intl';\n\nimport CheckBox from '../widgets/checkbox.jsx';\nimport VisiblePassword from '../widgets/visible-password.jsx';\n\nexport default class LoginView extends React.Component {\n constructor(props) {\n super(props);\n\n this.state = {\n login: props.login,\n password: '',\n hostName: props.serverAddress,\n saveToken: props.persist\n };\n this.handleLoginChange = this.handleLoginChange.bind(this);\n this.handlePasswordChange = this.handlePasswordChange.bind(this);\n this.handleToggleSaveToken = this.handleToggleSaveToken.bind(this);\n this.handleSubmit = this.handleSubmit.bind(this);\n }\n\n handleLoginChange(e) {\n this.setState({login: e.target.value});\n }\n\n handlePasswordChange(e) {\n this.setState({password: e.target.value});\n }\n\n handleToggleSaveToken() {\n this.props.onPersistenceChange(!this.state.saveToken);\n this.setState({saveToken: !this.state.saveToken});\n }\n\n handleSubmit(e) {\n e.preventDefault();\n this.props.onLogin(this.state.login.trim(), this.state.password.trim());\n }\n\n render() {\n let submitClasses = 'primary';\n if (this.props.disabled) {\n submitClasses += ' disabled';\n }\n\n return (\n \n );\n }\n};\n","import React from 'react';\nimport { Tinode } from 'tinode-sdk';\n\nimport HashNavigation from '../lib/navigation.js';\nimport { getBootstrap, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';\nimport { makeImageUrl } from '../lib/blob-helpers.js';\nimport { useCachedData } from '../lib/use-cached-data.js';\nimport { topicDisplayName } from '../lib/utils.js';\n\n// Must go through setUrlSidePanel (not a bare navigateTo(view), which replaces the whole hash) so\n// switching panels from \"messages-home\" doesn't silently drop path[1] -- the currently open\n// conversation topic, which coexists with this panel in the split view once a chat is selected.\nconst navigate = view => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, view));\n\n// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same\n// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by\n// a user screenshot showing every contact opening the identical \"接语彤\" script). Real \"frequently\n// contacted\" data doesn't exist as its own bootstrap field, so this derives it from the same\n// `chatList` the conversation list below already uses: P2P topics only (a \"people you talk to\" rail\n// isn't the place for groups), most-recently-touched first.\nfunction frequentContacts(chatList) {\n if (!chatList || chatList.length === 0) return [];\n return chatList\n .filter(item => item.topic && Tinode.isP2PTopicName(item.topic))\n .slice()\n .sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime())\n .slice(0, 8)\n .map(item => ({\n id: item.topic,\n name: (item.public && item.public.fn) || '未命名',\n avatar: makeImageUrl(item.public && item.public.photo),\n online: !!item.online\n }));\n}\n\n// Matches docs/images/消息页面—长按弹出.png exactly (9 items, in this order -- no \"编辑\" despite\n// bootstrap.message_policy.supported_actions listing it; the mock predates that field and the\n// prototype just doesn't surface an edit action here). 'recall' is gated against supported_actions\n// (server/im_http.go serveIMBootstrap); favorite/forward/translate/select are pure client affordances.\nconst MESSAGE_ACTIONS = [\n {id: 'quote', icon: 'format_quote', label: '引用'},\n {id: 'copy', icon: 'content_copy', label: '复制'},\n {id: 'favorite', icon: 'inventory_2', label: '收藏'},\n {id: 'translate', icon: 'translate', label: '翻译'},\n {id: 'forward', icon: 'reply', label: '转发'},\n {id: 'recall', icon: 'undo', label: '撤回', gated: true},\n {id: 'delete', icon: 'delete_outline', label: '删除', gated: true},\n {id: 'select', icon: 'select_all', label: '多选'},\n // Not in bootstrap.message_policy.supported_actions at all (that list only covers 'recall' for the\n // sender's own message) -- left ungated like the other pure client affordances above.\n {id: 'group_recall', icon: 'restart_alt', label: '群撤回'}\n];\n\nfunction Icon({children}) {\n return {children};\n}\n\nfunction Toast({message, onDone}) {\n React.useEffect(() => {\n if (!message) return undefined;\n const timer = window.setTimeout(onDone, 2200);\n return () => window.clearTimeout(timer);\n }, [message, onDone]);\n return message ? {message}
: null;\n}\n\nfunction Avatar({item, small = false}) {\n // The tone modifier is namespaced ('tone-orange', not 'orange') because base.css's original\n // Tinode styles already define bare `.orange`/`.green`/`.blue` as generic `color: ... !important`\n // text-color utility classes -- an unprefixed tone class collides with those and, since\n // `!important` beats any selector this component could use to fight back, silently overrides the\n // intended white icon-on-tint background to whatever that generic utility's text color is (this is\n // exactly why the default/no-tone case rendered an invisible blue-on-blue icon: 'blue' is one of\n // the collision names).\n const className = 'messages-avatar ' + (small ? 'small ' : '') + 'tone-' + (item.tone || 'blue');\n return \n {item.avatar ?

:
{item.icon || 'person'}}\n {item.online !== undefined ?
: null}\n
;\n}\n\n// A chat list row needs a one-line summary even when the latest message isn't plain text. This is a\n// plain-Chinese equivalent of lib/formatters.js's previewFormatter (which needs react-intl wired in,\n// inconsistent with this file's all-hardcoded-Chinese mock strings) -- just enough to label the\n// message by Drafty entity type for a conversation list preview.\nfunction draftyPreview(content) {\n if (typeof content === 'string' || content == null) return content || '';\n if (Array.isArray(content.ent) && content.ent.length) {\n const label = {IM: '[图片]', VD: '[视频]', AU: '[语音]', EX: '[文件]', TC: '[名片]', VC: '[通话]'}[content.ent[0].tp];\n if (label) return label;\n }\n return content.txt || '[消息]';\n}\n\nfunction formatConversationTime(ts) {\n if (!ts) return '';\n const date = new Date(ts);\n const now = new Date();\n const startOfDay = d => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();\n const days = Math.round((startOfDay(now) - startOfDay(date)) / 86400000);\n if (days === 0) return date.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'});\n if (days === 1) return '昨天';\n if (days < 7) return ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][date.getDay()];\n return date.toLocaleDateString('zh-CN', {month: 'numeric', day: 'numeric'});\n}\n\n// Entries (system messages / file assistant) come from bootstrap.entries and are always shown --\n// unlike the old mock, they don't disappear once the user has real conversations (that was a bug).\nfunction entryConversations(bootstrap) {\n if (!bootstrap || !bootstrap.entries) return [];\n const list = [];\n const sys = bootstrap.entries.system_messages;\n if (sys && sys.enabled) {\n // Matches docs/images/消息.png (\"最新公告\"). bootstrap doesn't carry a preview of the latest\n // notification, so this is a static label like the rest of that screenshot's demo content.\n list.push({id: 'system', title: '系统消息', summary: '最新公告', icon: 'notifications', tone: 'orange',\n system: true, unread: sys.unread_count || 0});\n }\n const files = bootstrap.entries.file_assistant;\n if (files && files.enabled) {\n list.push({id: 'files', title: '文件助手', summary: '你上传1份文件', icon: 'folder', tone: 'green',\n fileAssistant: true, topic: files.topic, unread: files.unread_count || 0});\n }\n return list;\n}\n\nfunction normalizeConversations(chatList, myUserId) {\n if (!chatList || chatList.length === 0) return [];\n return chatList.map((item, index) => {\n const latest = typeof item.latestMessage === 'function' ? item.latestMessage() : null;\n let summary = item.private && item.private.comment ? item.private.comment : '暂无消息';\n if (latest) summary = draftyPreview(latest.content);\n if (latest && latest.from === myUserId) summary = '我:' + summary;\n return {\n id: item.topic || item.user || String(index),\n title: topicDisplayName(item.topic, item.public, '未命名会话'),\n summary,\n time: formatConversationTime(item.touched),\n unread: Math.max(Number(item.unread) || 0, 0),\n online: !!item.online,\n muted: !!(item.acs && item.acs.isMuted && item.acs.isMuted()),\n pinned: !!item.pinned\n };\n });\n}\n\nfunction BottomNavigation() {\n return ;\n}\n\nfunction QuickMenu({onClose, onAction, permissions}) {\n const actions = [\n {id: 'friends', icon: 'group', label: '朋友'},\n {id: 'profile', icon: 'person_outline', label: '我的'},\n {id: 'create-group', icon: 'add_comment', label: '创建群组', permission: 'create_group'},\n {id: 'add-friend', icon: 'person_add_alt', label: '添加好友', permission: 'user_search'},\n {id: 'scan', icon: 'qr_code_scanner', label: '扫一扫', permission: 'scan_qr'}\n ].filter(item => !item.permission || !permissions || permissions[item.permission] !== false);\n return \n
e.stopPropagation()} role=\"menu\">\n {actions.map(item => )}\n
\n
;\n}\n\nfunction MessageHome({view, connected, chatList, myUserId, onTopicSelected, onOpenScanner}) {\n const [query, setQuery] = React.useState('');\n const [menuOpen, setMenuOpen] = React.useState(view === 'messages-menu');\n const [toast, setToast] = React.useState('');\n // Was `useState(null)` + its own effect -- refetched from scratch (blank entries/badges for a\n // beat) every time this component remounted, e.g. after a trip to another sidepanel and back.\n const [bootstrap] = useCachedData(myUserId ? 'bootstrap:' + myUserId : null, getBootstrap, [myUserId]);\n const offline = view === 'messages-offline' || (!!myUserId && connected === false);\n\n const conversations = entryConversations(bootstrap).concat(normalizeConversations(chatList, myUserId));\n const filtered = conversations.filter(item => `${item.title}${item.summary}`.toLowerCase().includes(query.trim().toLowerCase()));\n const frequent = frequentContacts(chatList);\n\n const selectConversation = item => {\n if (item.system) {\n navigate('system-messages');\n } else if (item.fileAssistant && item.topic && onTopicSelected) {\n onTopicSelected(item.topic);\n } else if (chatList && chatList.length && onTopicSelected) {\n onTopicSelected(item.id);\n } else {\n navigate('chat-standard');\n }\n };\n\n const runQuickAction = item => {\n setMenuOpen(false);\n if (item.id === 'friends') return navigate('address-book');\n if (item.id === 'profile') return navigate('personal-center');\n if (item.id === 'create-group') return navigate('group-list');\n if (item.id === 'add-friend') return navigate('add-friend');\n if (item.id === 'scan' && onOpenScanner) return onOpenScanner();\n setToast(item.id === 'scan' ? '扫码功能暂不可用' : `${item.label}页面将在对应章节中提供`);\n };\n\n return \n
\n {offline ? 加载中
: 消息
}\n \n \n
\n \n {frequent.length > 0 ? \n 常联系的人
\n \n {frequent.map(item =>
)}\n
\n : null}\n \n {filtered.map(item => )}\n {filtered.length === 0 ? : null}\n \n \n
\n {menuOpen ?
setMenuOpen(false)} onAction={runQuickAction} permissions={bootstrap && bootstrap.permissions} /> : null}\n setToast('')} />\n ;\n}\n\nfunction MessageActions() {\n const [visible, setVisible] = React.useState(true);\n const [toast, setToast] = React.useState('');\n const [selected, setSelected] = React.useState('');\n const [supportedActions, setSupportedActions] = React.useState(null);\n\n React.useEffect(() => {\n getBootstrap().then(data => setSupportedActions(data.message_policy && data.message_policy.supported_actions)).catch(() => {});\n }, []);\n\n // This screen is a static illustration of the long-press menu (not attached to a real message), so\n // there's no real (topic, seq) to act on -- the real wiring belongs in messages-view.jsx (chapter 4)\n // where an actual message exists. Here we only let the server's declared capabilities decide which\n // gated actions (recall/edit/delete) are worth showing at all.\n const actions = MESSAGE_ACTIONS.filter(item => !item.gated || !supportedActions || supportedActions.includes(item.id));\n\n const runAction = item => {\n setSelected(item.id);\n if (item.id === 'copy' && navigator.clipboard) {\n navigator.clipboard.writeText('明天上午十点开项目会,请准时参加。').catch(() => {});\n }\n setToast(`${item.label}操作已选择`);\n setVisible(false);\n };\n\n return \n
项目协作群
\n
\n 今天 10:24
\n \n \n {selected ? 已选择:{MESSAGE_ACTIONS.find(item => item.id === selected).label}
: null}\n \n
\n {visible ?
setVisible(false)}>\n
e.stopPropagation()} role=\"menu\">\n {actions.map(item => )}\n
\n
: null}\n
setToast('')} />\n ;\n}\n\nfunction SystemMessages({authenticated}) {\n const [items, setItems] = React.useState([]);\n const [loading, setLoading] = React.useState(true);\n const [error, setError] = React.useState('');\n const [toast, setToast] = React.useState('');\n\n React.useEffect(() => {\n getSystemNotifications().then(data => setItems(data.items || []))\n .catch(err => setError(err.message || '加载失败'))\n .finally(() => setLoading(false));\n }, []);\n\n const markRead = item => {\n if (item.read_at) return;\n setItems(list => list.map(i => i.id === item.id ? {...i, read_at: new Date().toISOString()} : i));\n markSystemNotificationRead(item.id).catch(() => {\n // Roll the optimistic update back on failure -- otherwise the \"new\" badge silently vanishes\n // even though the server never actually recorded this notification as read.\n setItems(list => list.map(i => i.id === item.id ? {...i, read_at: null} : i));\n });\n };\n\n return \n
系统消息
\n
\n {loading ? hourglass_empty加载中…
: null}\n {error ? error_outline{error}
: null}\n {!loading && !error && items.length === 0 ? notifications_none暂无系统消息
: null}\n {items.map(item => markRead(item)}>\n \n \n
{item.title}
{!item.read_at ? 新 : null}\n
{item.summary}
\n {item.action && item.action.device ?
- 设备:
- {item.action.device}
{item.action.ip ? - IP地址:
- {item.action.ip}
: null}
: null}\n
\n
\n
\n )}\n \n
setToast('')} />\n ;\n}\n\nexport default function MessageCenterView(props) {\n switch (props.view) {\n case 'system-messages': return ;\n case 'message-actions': return ;\n case 'messages-offline':\n case 'messages-menu':\n case 'messages-home':\n default: return ;\n }\n}\n","import React from 'react';\n\nimport HashNavigation from '../lib/navigation.js';\n\nconst navigate = view => HashNavigation.navigateTo(view);\n\nexport const MESSAGE_COMPOSER_VIEWS = [\n 'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',\n 'emoji-panel', 'emoji-message', 'voice-ready', 'voice-hold', 'voice-recording',\n 'media-uploading', 'media-gallery', 'media-retry', 'upload-file', 'file-message',\n 'message-multiselect', 'favorite-success', 'forward-select', 'forward-multiple',\n 'forward-individual', 'forward-bundle'\n];\n\nconst MEMBERS = [\n {id: 'all', name: '所有人', note: '提醒群内所有成员', avatar: 'campaign'},\n {id: 'annie', name: '安妮', note: '产品设计组', image: 'img/bkg/p09.jpg'},\n {id: 'lizi', name: '栗子来了', note: '前端开发组', image: 'img/bkg/p15.jpg'},\n {id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'},\n {id: 'chen', name: '陈晨', note: '项目管理组', image: 'img/bkg/p05.jpg'}\n];\n\nconst TARGETS = [\n {id: 'file', name: '文件助手', note: '仅自己可见', icon: 'folder_special'},\n {id: 'team', name: '产品研发群', note: '18人', image: 'img/bkg/p22.jpg'},\n {id: 'annie', name: '安妮', note: '在线', image: 'img/bkg/p09.jpg'},\n {id: 'lizi', name: '栗子来了', note: '刚刚活跃', image: 'img/bkg/p15.jpg'},\n {id: 'wang', name: '王珊珊', note: '市场运营组', image: 'img/bkg/p14.jpg'}\n];\n\nconst EMOJIS = ['😀', '😄', '😂', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '💡', '✅', '🙏', '💪', '🌟', '📌', '🚀'];\n\nfunction Icon({children}) {\n return {children};\n}\n\nfunction Avatar({item}) {\n return \n {item.image ?
: {item.icon || item.avatar || 'person'}}\n ;\n}\n\nfunction Header({title = '产品研发群', subtitle = '18人', onMore}) {\n return ;\n}\n\nfunction Toast({message, onDone}) {\n React.useEffect(() => {\n if (!message) return undefined;\n const timer = window.setTimeout(onDone, 2000);\n return () => window.clearTimeout(timer);\n }, [message, onDone]);\n return message ? {message}
: null;\n}\n\nfunction VoiceBubble({mine = false, duration = 8, unread = false}) {\n return \n {!mine ?
: null}\n
\n {mine ?
: null}\n
;\n}\n\nfunction TextBubble({mine = false, children, selected, onSelect}) {\n return \n {selected !== undefined ?
: null}\n
\n {!mine ?
: null}\n
{children}
\n {mine ?
: null}\n
\n
;\n}\n\nfunction ImageBubble({progress, failed = false, selected, onSelect}) {\n return \n {selected !== undefined ?
: null}\n
\n

\n {progress < 100 || failed ?
{failed ? 'refresh' : 'cloud_upload'}{failed ? '上传失败,点击重试' : `${progress}%`}{!failed ?
: null}
: null}\n
\n
\n
\n
;\n}\n\nfunction VideoBubble() {\n return ;\n}\n\nfunction FileBubble({progress = 100, name = '产品需求说明书.pdf', selected, onSelect}) {\n return \n {selected !== undefined ?
: null}\n
PDF{name}2.4 MB · {progress < 100 ? `上传中 ${progress}%` : '已发送'}{progress < 100 ?
: null}
{progress < 100 ? 'close' : 'download'} \n
;\n}\n\nfunction BaseTimeline({mode, uploadProgress, selected, toggleSelected}) {\n const selectable = mode === 'message-multiselect' || mode === 'favorite-success';\n return \n 今天 10:24
\n 上午的评审结论已经整理好了,大家有空看一下。\n toggleSelected('text')}>好的,我补充一下交互细节。\n {mode === 'mentions-message' ? @安妮 @栗子来了 下午三点一起确认新版流程。 : null}\n {['media-uploading', 'media-gallery', 'media-retry'].includes(mode) ? <>\n \n \n {mode === 'media-gallery' ? <>> : null}\n > : null}\n {['file-message', 'message-multiselect', 'favorite-success'].includes(mode) ? toggleSelected('file')} /> : null}\n {selectable ? toggleSelected('image')} /> : null}\n ;\n}\n\nfunction EmojiPanel({onEmoji, onDelete}) {\n return \n \n {EMOJIS.map(emoji => )}
\n \n ;\n}\n\nfunction Composer({mode, draft, setDraft, onSend, setMode}) {\n const voice = ['voice-ready', 'voice-hold', 'voice-recording'].includes(mode);\n const emoji = ['emoji-panel', 'emoji-message'].includes(mode);\n const keyDown = event => {\n if (event.key === '@' && mode !== 'mentions-message') window.setTimeout(() => navigate('mentions-select'), 0);\n };\n return <>\n \n {emoji ? setDraft(draft + value)} onDelete={() => setDraft(draft.slice(0, -1))} /> : null}\n >;\n}\n\nfunction VoiceOverlay({cancel = false}) {\n return \n
{cancel ? 'delete_outline' : 'mic'}\n
{[2, 5, 8, 12, 9, 6, 11, 4].map((height, index) => )}
\n
{cancel ? '松开手指,取消发送' : '手指上滑,取消发送'}00:08\n
;\n}\n\nfunction MemberPicker({complete}) {\n const [query, setQuery] = React.useState('');\n const [selected, setSelected] = React.useState(complete ? ['annie', 'lizi'] : []);\n const visible = MEMBERS.filter(item => item.name.includes(query) || item.note.includes(query));\n const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));\n return \n
选择提醒人
\n
\n
群成员
\n
{visible.map(item =>
)}
\n
;\n}\n\nfunction UploadFilePage({onToast}) {\n const [files, setFiles] = React.useState([{name: '产品需求说明书.pdf', size: '2.4 MB', progress: 64}]);\n React.useEffect(() => {\n if (files.every(file => file.progress >= 100)) return undefined;\n const timer = window.setTimeout(() => setFiles(items => items.map(file => ({...file, progress: Math.min(100, file.progress + 6)}))), 300);\n return () => window.clearTimeout(timer);\n }, [files]);\n return ;\n}\n\nfunction ForwardPage({mode, onToast}) {\n const multi = mode !== 'forward-select';\n const confirm = mode === 'forward-individual' || mode === 'forward-bundle';\n const [selected, setSelected] = React.useState(() => {\n if (!confirm) return [];\n try {\n return JSON.parse(sessionStorage.getItem('im-forward-targets')) || ['team', 'annie'];\n } catch (_) {\n return ['team', 'annie'];\n }\n });\n const [query, setQuery] = React.useState('');\n const [comment, setComment] = React.useState('');\n const toggle = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : multi ? values.concat(id).slice(0, 9) : [id]);\n const visible = TARGETS.filter(item => item.name.includes(query) || item.note.includes(query));\n return \n
{confirm ? (mode === 'forward-bundle' ? '合并转发' : '逐条转发') : '选择转发对象'}
{!confirm ? : }\n {!confirm ? <>
最近会话
{visible.map(item =>
)}
{multi ?
: null}> :
发送给
{TARGETS.filter(item => selected.includes(item.id)).map(item =>
{item.name})}
{mode === 'forward-bundle' ? '产品研发群的聊天记录' : '将逐条发送 3 条消息'}
栗子来了:上午的评审结论已经整理好了
我:好的,我补充一下交互细节。
我:[图片]
}\n
;\n}\n\nexport default function MessageComposerView({view}) {\n const [mode, setMode] = React.useState(view);\n const [draft, setDraftState] = React.useState(() => sessionStorage.getItem('im-message-composer-draft') || (view === 'emoji-message' ? '收到啦 ' : ''));\n const [uploadProgress, setUploadProgress] = React.useState(view === 'media-uploading' ? 22 : 100);\n const [selected, setSelected] = React.useState(view === 'favorite-success' ? ['text', 'file', 'image'] : ['text', 'image']);\n const [toast, setToast] = React.useState(view === 'favorite-success' ? '已收藏 3 条消息' : '');\n const setDraft = value => {setDraftState(value); sessionStorage.setItem('im-message-composer-draft', value);};\n\n React.useEffect(() => {\n if (mode !== 'media-uploading' || uploadProgress >= 100) return undefined;\n const timer = window.setTimeout(() => setUploadProgress(value => Math.min(100, value + 5)), 350);\n return () => window.clearTimeout(timer);\n }, [mode, uploadProgress]);\n\n if (view === 'mentions-select' || view === 'mentions-complete') return ;\n if (view === 'upload-file') return <> setToast('')} />>;\n if (['forward-select', 'forward-multiple', 'forward-individual', 'forward-bundle'].includes(view)) return <> setToast('')} />>;\n\n const toggleSelected = id => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : values.concat(id));\n const recording = mode === 'voice-hold' || mode === 'voice-recording';\n const multiselect = mode === 'message-multiselect' || mode === 'favorite-success';\n\n return \n
setToast('会话设置')} />\n {multiselect ? 已选择 {selected.length} 条
: null}\n \n {multiselect ? : {if (!draft.trim()) return; setToast('消息已发送'); setDraft('');}} />}\n {recording ? : null}\n setToast('')} />\n ;\n}\n","// Create new topic and invite users or send an invite.\nimport React from 'react';\nimport { FormattedMessage, defineMessages, injectIntl } from 'react-intl';\n\nimport { Tinode } from 'tinode-sdk';\n\nimport ContactList from '../widgets/contact-list.jsx';\nimport NewTopicById from '../widgets/new-topic-by-id.jsx';\nimport NewTopicGroup from '../widgets/new-topic-group.jsx';\nimport SearchContacts from '../widgets/search-contacts.jsx';\n\nimport HashNavigation from '../lib/navigation.js';\nimport { asEmail, asPhone, theCard } from '../lib/utils.js';\n\nconst messages = defineMessages({\n search_for_contacts: {\n id: \"search_for_contacts\",\n defaultMessage: \"Use search to find contacts\",\n description: \"Text shown in contacts view when user entered no search query.\"\n },\n search_no_results: {\n id: \"search_no_results\",\n defaultMessage: \"Search returned no results\",\n description: \"Text shown in contacts view when query returned no results.\"\n },\n search_placeholder: {\n id: \"search_placeholder\",\n defaultMessage: \"List like alice@example.com, +17025550003...\",\n description: \"Placeholder in contacts search field\"\n },\n});\n\nclass NewTopicView extends React.Component {\n constructor(props) {\n super(props);\n\n const urlParams = HashNavigation.parseUrlHash(window.location.hash).params;\n const initialTab = urlParams['tab'] || 'find';\n const initialQuery = initialTab == 'find' ? (urlParams['q'] || null) : null;\n\n this.state = {\n tabSelected: initialTab,\n searchQuery: initialQuery\n };\n\n this.handleTabClick = this.handleTabClick.bind(this);\n this.handleSearchContacts = this.handleSearchContacts.bind(this);\n this.handleSearchResultSelected = this.handleSearchResultSelected.bind(this);\n this.handleNewGroupSubmit = this.handleNewGroupSubmit.bind(this);\n this.handleGroupByID = this.handleGroupByID.bind(this);\n }\n\n componentDidMount() {\n this.props.onInitFind();\n }\n\n handleTabClick(e) {\n e.preventDefault();\n HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));\n this.setState({tabSelected: e.currentTarget.dataset.id});\n }\n\n handleSearchContacts(query) {\n query = query.trim() || Tinode.DEL_CHAR;\n if (!/[\\s,:]/.test(query) && query != Tinode.DEL_CHAR) {\n // No colons, spaces or commas, not DEL char. Try as email, phone, or alias.\n const email = asEmail(query);\n if (email) {\n query = `${Tinode.TAG_EMAIL}${email}`;\n } else {\n const tel = asPhone(query);\n if (tel) {\n query = `${Tinode.TAG_PHONE}${tel}`;\n } else {\n if (query[0] == '@') {\n // Strip the leading '@' sign if present.\n query = query.substring(1);\n }\n // 'alice' -> 'alias:alice,alice'\n query = `${Tinode.TAG_ALIAS}${query},${query}`;\n }\n }\n }\n this.props.onSearchContacts(query);\n this.setState({searchQuery: Tinode.isNullValue(query) ? null : query});\n }\n\n handleSearchResultSelected(topicName) {\n if (this.state.tabSelected == 'find') {\n HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));\n this.props.onCreateTopic(topicName);\n }\n }\n\n handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {\n HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));\n this.props.onCreateTopic(undefined,\n {public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);\n }\n\n handleGroupByID(topicName) {\n HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));\n this.props.onCreateTopic(topicName);\n }\n\n render() {\n const {formatMessage} = this.props.intl;\n const no_contacts_placeholder = formatMessage(this.state.searchQuery ?\n messages.search_no_results : messages.search_for_contacts);\n const search_placeholder = formatMessage(messages.search_placeholder);\n return (\n \n
\n {this.state.tabSelected === 'grp' ?\n
:\n\n this.state.tabSelected === 'byid' ?\n
:\n\n
\n \n \n
}\n
\n );\n }\n};\n\nexport default injectIntl(NewTopicView);\n","import React from 'react';\nimport QRCode from 'qrcodejs';\n\nimport HashNavigation from '../lib/navigation.js';\nimport { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';\nimport { BASE_APP_NAME } from '../config.js';\nimport { PACKAGE_VERSION } from '../version.js';\nimport {\n getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,\n getMePrivacy, updateMePrivacy, getYouthMode, enableYouthMode, disableYouthMode, verifyIndependentPassword,\n createMyQRCode, getRealnameStatus\n} from '../lib/im-api.js';\n\nconst navigate = (view, params) => {\n let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);\n Object.entries(params || {}).forEach(([key, value]) => {\n hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);\n });\n HashNavigation.navigateTo(hash);\n};\n\nexport const PERSONAL_SETTINGS_VIEWS = [\n 'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',\n 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',\n 'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',\n 'app-version', 'privacy-settings', 'friend-discovery-settings',\n 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',\n 'notification-permission', 'permission-settings-reminder', 'camera-permission-info',\n 'microphone-permission-info'\n];\n\nfunction Icon({children}) { return {children}; }\n\nfunction Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {\n return \n \n {title}
\n {action ? : }\n ;\n}\n\nfunction Toast({message, onDone}) {\n React.useEffect(() => {\n if (!message) return undefined;\n const timer = window.setTimeout(onDone, 2100);\n return () => window.clearTimeout(timer);\n }, [message, onDone]);\n return message ? {message}
: null;\n}\n\nfunction Toggle({checked, disabled, onChange}) {\n return ;\n}\n\nfunction MenuRow({icon, title, value, route, onClick, danger}) {\n return ;\n}\n\n// docs/images/我的.png 的\"企业名称\"字段实际上是租户名,不是组织架构的部门 -- 复用 Ch.9 workbench\n// 已经验证过的读取方式(登录时 tenants/resolve 缓存进 localStorage['im-tenant'])。\nfunction tenantName() {\n try { return JSON.parse(window.localStorage.getItem('im-tenant') || '{}').name || ''; } catch (_) { return ''; }\n}\n\nfunction ImgFromFile({fileId, className}) {\n const [url, setUrl] = React.useState(null);\n React.useEffect(() => { let live = true; getFileUrl(fileId).then(u => live && setUrl(u)).catch(() => {}); return () => { live = false; }; }, [fileId]);\n return url ?
: ;\n}\n\nfunction PersonalCenter() {\n // Was `useState(null)` + its own effect -- remounting this (e.g. tabbing back from 设置) blanked\n // the name/avatar back to the loading placeholder for a beat even though nothing had changed,\n // same \"switching pages feels like it reloads every time\" complaint this cache already fixed for\n // the workbench/message list/address book.\n const [profile] = useCachedData('me-profile', getMeProfile, []);\n return \n \n \n \n ;\n}\n\n// docs/images/个人信息.png 两张截图字段不完全一样(一张有用户名/职位/公司,另一张有ID号/地区)--\n// 代码调研确认 /me/profile 真实字段只有 avatar/nickname/gender/birth_date/region/signature/email;\n// \"职位/公司\"属于第9章组织架构数据源(这里没有反查接口,不展示);\"ID号\"、\"用户名\"是原型臆造字段,\n// 后端没有对应概念(用户id恒为usrXXX格式),不做。\nfunction PersonalProfile({onToast, tinode}) {\n // Shares the 'me-profile' key with PersonalCenter's already-fixed cache -- this is the hub every\n // field editor below navigates back to on save, so it's revisited constantly.\n const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);\n const fileInputRef = React.useRef(null);\n const [uploading, setUploading] = React.useState(false);\n\n const load = () => getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});\n React.useEffect(() => { load(); }, []);\n\n const pickAvatar = () => fileInputRef.current && fileInputRef.current.click();\n const handleAvatarFile = event => {\n const file = event.target.files && event.target.files[0];\n event.target.value = '';\n if (!file || !tinode) return;\n setUploading(true);\n uploadLargeFile(tinode, file).then(({fileId}) => updateMeProfile({avatar_file_id: fileId, version: profile.version}))\n .then(load).then(() => onToast('头像已更新'))\n .catch(err => onToast(err.message || '头像上传失败')).finally(() => setUploading(false));\n };\n\n const genderLabel = {male: '男', female: '女', other: '其他'}[profile && profile.gender] || '未设置';\n\n return ;\n}\n\n// Real field_rules confirmed against server/im_http.go: nickname max_length=32, signature\n// max_length=120 -- the reference screenshots show 120/20 respectively (swapped and wrong),\n// mock code already had the right numbers by luck; kept here but sourced from a constant that\n// matches the server contract rather than the screenshots.\nconst FIELD_LIMITS = {nickname: 32, signature: 120};\n\nfunction TextEditor({field, multiline, title, onToast}) {\n const [profile, setProfile] = React.useState(null);\n const [value, setValue] = React.useState('');\n const [saving, setSaving] = React.useState(false);\n React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p[field] || ''); }).catch(() => {}); }, [field]);\n\n const save = () => {\n const normalized = value.trim();\n if (field === 'nickname' && !normalized) { onToast('昵称不能为空'); return; }\n setSaving(true);\n updateMeProfile({[field]: normalized, version: profile.version}).then(_ => {\n onToast('资料已保存');\n window.setTimeout(() => navigate('personal-profile'), 500);\n }).catch(err => onToast(err.message || '保存失败')).finally(() => setSaving(false));\n };\n\n const maxLength = FIELD_LIMITS[field];\n return \n
\n
\n \n {field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}
\n \n
;\n}\n\nfunction BirthDateEditor({onToast}) {\n const [profile, setProfile] = React.useState(null);\n const [value, setValue] = React.useState('');\n React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.birth_date || ''); }).catch(() => {}); }, []);\n const save = () => {\n updateMeProfile({birth_date: value, version: profile.version}).then(_ => {\n onToast('出生日期已保存');\n window.setTimeout(() => navigate('personal-profile'), 500);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n const today = new Date().toISOString().slice(0, 10);\n return \n
\n
\n cake\n \n 出生日期的可见范围由隐私设置决定
\n \n
;\n}\n\nconst GENDER_OPTIONS = [{value: 'male', label: '男'}, {value: 'female', label: '女'}, {value: 'other', label: '其他'}];\n\nfunction GenderEditor({onToast}) {\n const [profile, setProfile] = React.useState(null);\n React.useEffect(() => { getMeProfile().then(setProfile).catch(() => {}); }, []);\n const choose = value => {\n updateMeProfile({gender: value, version: profile.version}).then(_ => {\n onToast('性别已更新');\n window.setTimeout(() => navigate('personal-profile'), 400);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n return \n {GENDER_OPTIONS.map(opt => )}\n
;\n}\n\nfunction RegionEditor({onToast}) {\n const [profile, setProfile] = React.useState(null);\n const [value, setValue] = React.useState('');\n React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.region || ''); }).catch(() => {}); }, []);\n const save = () => {\n updateMeProfile({region: value.trim(), version: profile.version}).then(_ => {\n onToast('地区已保存');\n window.setTimeout(() => navigate('personal-profile'), 500);\n }).catch(err => onToast(err.message || '保存失败'));\n };\n return \n \n \n
;\n}\n\nfunction PersonalQr({onToast}) {\n // Same shared 'me-profile' key as PersonalCenter/PersonalProfile -- this only needs it for the\n // avatar/name display next to the code. `qr` itself is deliberately NOT cached: createMyQRCode()\n // mints/renews a signed code each call (POST semantics, not a GET), so seeding a stale payload\n // risks showing an already-superseded code that fails to scan -- worse than a brief spinner.\n const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);\n const [qr, setQr] = React.useState(null);\n const qrRef = React.useRef(null);\n\n const load = () => {\n createMyQRCode().then(setQr).catch(err => onToast(err.message || '二维码生成失败'));\n };\n React.useEffect(() => {\n getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});\n load();\n }, []);\n\n React.useEffect(() => {\n if (!qr || !qrRef.current) return;\n qrRef.current.innerHTML = '';\n new QRCode(qrRef.current, {text: qr.qr_payload || qr.qr_token, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});\n }, [qr]);\n\n return \n \n \n {profile && profile.avatar_file_id ?
:
person}\n
{(profile && profile.nickname) || (profile && profile.public_account)}{tenantName()}\n
\n \n 扫一扫上面的二维码,加我为好友
\n {qr && qr.expires_at ? 二维码将于 {new Date(qr.expires_at).toLocaleString()} 失效 : null}\n \n \n \n \n
\n ;\n}\n\n// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了\"青少年模式\"/\"实名认证\"两行状态\n// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。\nfunction SettingsList({onToast, onLogout}) {\n // 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'\n // has no other reader today but gets the same treatment for consistency -- both status badges here\n // otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows\n // \"未认证\" the instant this remounts, even while the still-unauthenticated-looking fetch is in\n // flight) rather than a neutral loading placeholder, so caching avoids a *wrong* badge, not just a\n // blank one.\n const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);\n const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);\n React.useEffect(() => {\n getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});\n getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});\n }, []);\n const youthEnabled = youthSettings ? youthSettings.enabled : null;\n const realnameStatus = realname ? realname.status : null;\n const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';\n const logout = () => {\n if (window.confirm('确定要退出当前登录吗?')) {\n onLogout ? onLogout() : onToast('当前环境不支持退出登录');\n }\n };\n return \n \n \n \n \n
;\n}\n\nfunction ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {\n return \n {icon}{title}
{body}
\n {children}\n \n \n ;\n}\n\n// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:\n// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;\n// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same\n// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.\nfunction CacheClear({onToast}) {\n const [busy, setBusy] = React.useState(false);\n const [done, setDone] = React.useState(false);\n const [freed, setFreed] = React.useState(0);\n const confirm = async () => {\n setBusy(true);\n try {\n const before = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;\n const keys = await caches.keys();\n await Promise.all(keys.map(k => caches.delete(k)));\n const after = (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0;\n setFreed(Math.max(0, before - after));\n setDone(true);\n } finally {\n setBusy(false);\n }\n };\n return navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;\n}\n\nfunction ClearHistoryConfirm({tinode, chatList, onToast}) {\n const [busy, setBusy] = React.useState(false);\n const confirm = () => {\n if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }\n setBusy(true);\n Promise.all(chatList.map(c => {\n const topic = tinode.getTopic(c.name);\n return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();\n })).then(() => {\n onToast('聊天记录已清空');\n navigate('android-settings');\n }).finally(() => setBusy(false));\n };\n return navigate('android-settings')} />;\n}\n\n// Changing password has no REST endpoint -- it's native Tinode {acc} protocol\n// (Tinode.updateAccountBasic), already wired end-to-end via tinode-web.jsx's\n// handleUpdatePasswordRequest/onUpdatePassword prop (used today by the old acc-security-view.jsx).\n// Reusing that verified callback here instead of reimplementing the WS call.\nfunction ChangePassword({onUpdatePassword, onToast}) {\n const [oldPwd, setOldPwd] = React.useState('');\n const [newPwd, setNewPwd] = React.useState('');\n const [confirmPwd, setConfirmPwd] = React.useState('');\n const submit = () => {\n if (!oldPwd || newPwd.length < 6) { onToast('请输入原密码,新密码至少6位'); return; }\n if (newPwd !== confirmPwd) { onToast('两次输入的新密码不一致'); return; }\n if (!onUpdatePassword) { onToast('当前环境不支持修改密码'); return; }\n onUpdatePassword(newPwd);\n onToast('密码已更新');\n window.setTimeout(() => navigate('android-settings'), 500);\n };\n return ;\n}\n\n// Account deletion is also native Tinode ({del what=\"user\" hard=true} -> delCurrentUser(true)),\n// already wired via tinode-web.jsx's handleDeleteAccount/onDeleteAccount prop. Server-side there's\n// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a\n// password confirmation step here is a deliberate, conservative frontend-only safeguard given how\n// irreversible and consequential this action is, not something the backend requires.\nfunction DeleteAccountConfirm({onDeleteAccount, onToast}) {\n const [step, setStep] = React.useState(1);\n const [password, setPassword] = React.useState('');\n const [busy, setBusy] = React.useState(false);\n const proceed = () => {\n if (!password) { onToast('请输入密码以确认'); return; }\n if (!onDeleteAccount) { onToast('当前环境不支持注销账号'); return; }\n setBusy(true);\n Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });\n };\n if (step === 1) {\n return setStep(2)} onCancel={() => navigate('android-settings')} />;\n }\n return navigate('android-settings')}>\n setPassword(e.target.value)} placeholder=\"登录密码\" />\n ;\n}\n\n// docs/images/版本更新页面.png 其实是\"关于我们\"页(app图标+版本号+用户条款/隐私协议/检测新版本/联系我们),\n// 品牌名图片里出现了5个不同版本(捷信密使/光讯通讯/笔墨讯使/光迅通讯...),判定为设计稿混用了多版本草稿,\n// 统一用代码里已确立的 BASE_APP_NAME(纯品牌名,不带版本号后缀),不采信任何一个图片品牌名。\n// \"检测新版本\"(及跳转应用商店的更新弹窗)是原型给移动端设计的,web版没有应用商店可跳,按要求隐藏。\nfunction AppVersion({onToast}) {\n const currentVersion = PACKAGE_VERSION;\n\n return \n forum{BASE_APP_NAME}
版本 {currentVersion}
\n \n onToast('用户条款')} />\n onToast('隐私协议')} />\n \n \n
;\n}\n\n// docs/images/隐私设置.png 只画了\"添加我的方式\"+\"邀我入群需要验证\"两项,但 UserPrivacySettings\n// 结构体真实还有 profile_visibility/last_seen_visibility/read_receipts 三个字段 -- 图片大概率没拍全,\n// 这三项是真实、有意义的字段,保留;\"邀我入群需要验证\"反过来是图片有但后端完全没有对应字段(代码调研\n// 确认 UserPrivacySettings 里没有这个概念,群邀请入群逻辑本身也不检查任何隐私开关),故不做这一项,\n// 记录为后端缺口而不是造一个点了没有效果的假开关。\nfunction PrivacySettings({onToast}) {\n // Shares 'me-privacy' with FriendDiscoverySettings' identical getMePrivacy() call -- a toggle page,\n // not a text edit form, so a background refetch self-corrects any stale toggle state harmlessly\n // (no risk of clobbering in-progress typing the way a free-text field would).\n const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);\n React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);\n const update = (key, value) => {\n const prev = privacy;\n setPrivacy({...privacy, [key]: value});\n updateMePrivacy({[key]: value, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })\n .catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });\n };\n const methodSummary = () => {\n if (!privacy) return '';\n const labels = {account: '账号', phone: '手机号', qr: '二维码', group: '群聊'};\n const on = Object.entries(privacy.add_me_methods || {}).filter(([, v]) => v).map(([k]) => labels[k] || k);\n if (on.length === 4) return '全部允许';\n return on.length ? on.join('、') : '全部关闭';\n };\n return \n \n {privacy ? \n 显示消息已读状态关闭后你也无法查看对方的已读状态\n update('read_receipts', v)} />
\n 展示最后在线时间仅向联系人显示\n {/* Backend's validVisibility only accepts everyone/friends/nobody -- 'contacts'/'hidden' were\n rejected by the PATCH validator outright (every toggle attempt 400'd and silently reverted),\n and even a lenient backend would enforce the opposite of what turning this \"on\" should do\n (imCanViewLastSeen's default case treats any unrecognized value as hidden). */}\n update('last_seen_visibility', v ? 'friends' : 'nobody')} />
\n 允许好友查看我的资料关闭后仅你自己可见\n update('profile_visibility', v ? 'friends' : 'nobody')} />
\n : null}\n ;\n}\n\nfunction FriendDiscoverySettings({onToast}) {\n // Same shared 'me-privacy' key as PrivacySettings -- identical resource, so whichever of the two\n // screens is opened first warms the cache for the other.\n const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);\n React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);\n const update = (key, value) => {\n const prev = privacy;\n const nextMethods = {...(privacy.add_me_methods || {}), [key]: value};\n setPrivacy({...privacy, add_me_methods: nextMethods});\n updateMePrivacy({add_me_methods: nextMethods, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })\n .catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });\n };\n const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];\n return \n 关闭某种方式后,其他人将无法通过该渠道搜索或添加你。
\n {privacy ? {rows.map(row => \n 可通过{row.title}添加我\n update(row.key, v)} />\n
)} : null}\n ;\n}\n\n// docs/images/青少年模式未开启(安卓).png 的\"每日不超过40分钟\"/\"加好友群聊每日各4次\"是纯前端文案,\n// 代码调研确认 YouthModeSettings.Restrictions 后端真实内容是三个类别码\n// (night_quiet/content_filter/payment_guard),没有任何分钟数/次数字段 -- 这两个具体数字只作为说明\n// 文案展示,不冒充是从后端配置读出来的。使用时长同理没有后端字段,这里做的是纯本地、按自然日重置的\n// 近似计时(刷新/换设备会丢,不追求精确,比完全不显示更贴近原型但明确知道它不是权威数据)。\nconst YOUTH_RULES = [\n `为呵护未成年人健康成长,${BASE_APP_NAME}推出青少年模式。该模式下部分功能将受限制使用`,\n '每天开启后使用时长不超过 40 分钟',\n '限制加好友/群聊次数(主动/被动加好友或群聊每日最多各 4 次)'\n];\nconst YOUTH_USAGE_KEY = 'im-youth-mode-usage';\n\nfunction useYouthUsageSeconds(active) {\n const [seconds, setSeconds] = React.useState(0);\n React.useEffect(() => {\n const today = new Date().toISOString().slice(0, 10);\n let stored = {};\n try { stored = JSON.parse(window.localStorage.getItem(YOUTH_USAGE_KEY) || '{}'); } catch (_) { /* ignore */ }\n if (stored.date !== today) stored = {date: today, seconds: 0};\n setSeconds(stored.seconds || 0);\n if (!active) return undefined;\n const timer = window.setInterval(() => {\n setSeconds(s => {\n const next = s + 1;\n window.localStorage.setItem(YOUTH_USAGE_KEY, JSON.stringify({date: today, seconds: next}));\n return next;\n });\n }, 1000);\n return () => window.clearInterval(timer);\n }, [active]);\n return seconds;\n}\n\nfunction formatHMS(totalSeconds) {\n const h = Math.floor(totalSeconds / 3600);\n const m = Math.floor((totalSeconds % 3600) / 60);\n const s = totalSeconds % 60;\n const pad = n => String(n).padStart(2, '0');\n return `${pad(h)}:${pad(m)}:${pad(s)}`;\n}\n\nfunction YouthMode({onToast}) {\n // Shares 'youth-mode' with SettingsList's badge fetch (see comment there) and gets patched directly\n // by YouthModeVerify right after a successful enable/disable, so navigating back here shows the new\n // state immediately instead of the stale cached one for a moment before self-correcting.\n const [settings, setSettings] = React.useState(() => readCachedData('youth-mode') || null);\n const usage = useYouthUsageSeconds(settings && settings.enabled);\n React.useEffect(() => {\n setSettings(readCachedData('youth-mode') || null);\n getYouthMode().then(d => { writeCachedData('youth-mode', d); setSettings(d); }).catch(err => onToast(err.message || '加载失败'));\n }, []);\n\n const startEnable = () => {\n if (!settings) return;\n if (!settings.password_set) {\n onToast('请先设置独立密码');\n window.setTimeout(() => navigate('identity-check'), 400);\n return;\n }\n navigate('youth-mode-verify', {ym: 'enable'});\n };\n\n if (!settings) return ;\n\n return \n
\n
\n {settings.enabled ? 'verified_user' : 'child_care'}\n {settings.enabled ? '青少年模式已开启' : '青少年模式未开启'}
\n {YOUTH_RULES.map(rule => - {rule}
)}
\n {settings.enabled ? 今日已使用:{formatHMS(usage)}
: null}\n \n
\n
;\n}\n\n// docs/images/青少年模式提示(安卓).png 实际内容是\"今日已达40分钟拦截层\"(文件名误导),其中的\n// \"输入密码继续使用\"选项代码调研确认后端没有任何\"临时解锁但不关闭\"的操作可对应 -- serveIMYouthModeEnable\n// /Disable 只有开/关两种终态,没有\"本次会话继续\"的中间态。不做这个假按钮,只保留真实可行的\"关闭\"路径。\n//\n// 开关流程统一:已设置密码时,输入密码 -> POST .../independent-password/verify 换 action_token\n// (action 按 enable/disable 传 enable_youth_mode/close_youth_mode)-> 调 enable/disable。\n// 未设置密码时开启前必须先走 identity-check(短信验证码)设置密码,这一步不消费于开启本身。\nfunction YouthModeVerify({hashParams, onToast}) {\n const mode = (hashParams && hashParams.ym) || 'disable';\n const [settings, setSettings] = React.useState(null);\n const [password, setPassword] = React.useState('');\n const [error, setError] = React.useState('');\n const [busy, setBusy] = React.useState(false);\n\n React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);\n\n const submit = () => {\n if (password.length !== 4) { setError('请输入4位独立密码'); return; }\n setBusy(true);\n setError('');\n // Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'\n // match (ConsumeActionToken does an exact WHERE action=? lookup) -- minting it tagged\n // 'set_independent_password' meant the lookup could never find it, so every enable attempt\n // 403'd with ACTION_TOKEN_INVALID regardless of a correct password.\n const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode';\n verifyIndependentPassword(password, 'youth_mode', action).then(({action_token}) => {\n const call = mode === 'enable' ?\n enableYouthMode(settings.policy_version, action_token) :\n disableYouthMode(action_token);\n return call;\n }).then(_ => {\n // Patch the shared cache with the now-known new state before navigating back to YouthMode/\n // SettingsList -- without this they'd briefly show the pre-toggle cached value again and then\n // flip, which is a more confusing flash than the plain loading state they had before caching.\n if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});\n onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');\n window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);\n }).catch(err => setError(err.message || '验证失败')).finally(() => setBusy(false));\n };\n\n return \n
\n
\n {[0, 1, 2, 3].map(i => {password[i] ? '●' : ''})}
\n setPassword(e.target.value.replace(/\\D/g, '').slice(0, 4))} aria-label=\"独立密码\" />\n {error ? {error}
: null}\n \n \n
;\n}\n\n// 10.18-10.21:纯静态权限说明卡片,浏览器原生权限API(Notification.requestPermission()/\n// getUserMedia())本身没有\"用途说明\"这个中间步骤,所以这一层完全是前端自己在真正触发系统权限\n// 弹窗前插入的说明UI,不需要任何后端接口 -- 与代码调研确认的\"清缓存/清聊天记录\"是同一类判断。\nconst PERMISSIONS = {\n notification: {icon: 'notifications_active', title: '获取推送权限', text: '向您及时送达重要通知、活动提醒、个性化内容更新等信息。', action: '开启通知', route: 'permission-settings-reminder'},\n reminder: {icon: 'settings', title: '消息推送暂未开启', text: `前往【设置 - 通知管理】一键开启吧!`, action: '好的', secondaryAction: '暂不开启', route: 'android-settings'},\n camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '调用相机拍摄/扫码,为您提供拍照、扫码识别等服务。', action: '继续授权', route: 'messages-home'},\n microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}\n};\n\nfunction PermissionGuide({type, onToast}) {\n const config = PERMISSIONS[type];\n const proceed = () => {\n if (type === 'notification' && 'Notification' in window) {\n Notification.requestPermission().then(() => navigate(config.route));\n return;\n }\n onToast(type === 'reminder' ? '请前往系统设置开启通知' : '等待系统授权结果');\n window.setTimeout(() => navigate(config.route), 500);\n };\n return \n
\n
\n {config.icon}\n {config.title}
\n {config.text}
\n \n {config.secondaryAction ? : null}\n \n
;\n}\n\nexport default function PersonalSettingsView({view, hashParams, myUserId, tinode, chatList, onUpdatePassword, onDeleteAccount, onLogout}) {\n const [toast, setToast] = React.useState('');\n let content;\n if (view === 'personal-center') content = ;\n else if (view === 'personal-profile') content = ;\n else if (view === 'profile-nickname') content = ;\n else if (view === 'profile-signature') content = ;\n else if (view === 'profile-birth-date') content = ;\n else if (view === 'profile-gender') content = ;\n else if (view === 'profile-region') content = ;\n else if (view === 'personal-qrcode') content = ;\n else if (view === 'android-settings') content = ;\n else if (view === 'cache-clear-confirm') content = ;\n else if (view === 'clear-history-confirm') content = ;\n else if (view === 'change-password') content = ;\n else if (view === 'delete-account-confirm') content = ;\n else if (view === 'app-version') content = ;\n else if (view === 'privacy-settings') content = ;\n else if (view === 'friend-discovery-settings') content = ;\n else if (view === 'youth-mode-off' || view === 'youth-mode-on') content = ;\n else if (view === 'youth-mode-verify') content = ;\n else content = ;\n return <>{content} setToast('')} />>;\n}\n","import React from 'react';\n\nimport HashNavigation from '../lib/navigation.js';\nimport { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';\nimport { readCachedData, writeCachedData } from '../lib/use-cached-data.js';\n\nconst navigate = (view, params) => {\n let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);\n Object.entries(params || {}).forEach(([key, value]) => {\n hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);\n });\n HashNavigation.navigateTo(hash);\n};\n\nexport const SECURITY_SUPPORT_VIEWS = [\n 'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',\n 'abuse-report-details', 'user-feedback'\n];\n\nfunction Icon({children}) { return {children}; }\n\nfunction Header({title, back}) {\n return {title}
;\n}\n\nfunction Toast({message, onDone}) {\n React.useEffect(() => {\n if (!message) return undefined;\n const timer = window.setTimeout(onDone, 2200);\n return () => window.clearTimeout(timer);\n }, [message, onDone]);\n return message ? {message}
: null;\n}\n\n// Backend has no \"content risk scan\" or \"post-send fraud alert\" mechanism (confirmed against\n// im_be/server/im_http.go and session.go -- grepping for 诈骗/反诈/110/fraud in the whole backend\n// only turns up abuse-report reason labels, no scanning logic and no system-notification event type\n// for it). The two reference images (反诈弹窗(安卓).png / 反诈弹窗(二次提示安卓).png) show generic\n// anti-fraud education content that isn't tied to any specific message or backend-driven trigger --\n// there's no field anywhere (bootstrap, profile, settings) indicating \"has this been shown before\".\n// So this is implemented as a standalone, always-reachable safety-info page (not something that\n// pops up automatically), reusing the two designs' content/copy verbatim for their real interactive\n// counterpart: the pre-send URL risk check below (RiskConfirmDialog), which DOES have a real backend\n// (POST /security/urls/check + POST /security/risk-challenges/{id}/confirm).\nfunction FraudAwareness({secondary}) {\n return \n {secondary ? 'warning_amber' : 'shield_lock'}\n {secondary ? <>\n 您发送的内容可能涉及诈骗风险,请注意甄别。
\n 切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。
\n \n > : <>\n 为保障您的账号及资金安全,请您注意:
\n \n - receipt_long不参与刷单返利活动
\n - currency_yuan不轻信高收益投资、理财信息
\n - person_off不出租、出借、出售账号及实名信息
\n - lock不向任何人泄露验证码、密码等敏感信息
\n
\n 请提高安全防范意识,谨防电信网络诈骗。
\n >}\n \n ;\n}\n\n// Exported for messages-view.jsx to render inline when composing a message that contains a URL --\n// this is the one piece of Ch.11 with a genuine backend mechanism behind it (checkURL/confirmRiskChallenge,\n// verified against im_http.go:1184-1285). `level`/`blocked` come straight from the checkURL response.\nexport function RiskConfirmDialog({level, blocked, onConfirm, onCancel}) {\n return \n {blocked ? 'block' : 'warning_amber'}\n 安全提醒
\n {blocked ? 检测到该内容存在较高安全风险,暂无法发送。
: <>\n 您发送的内容可能涉及诈骗风险,请注意甄别。
\n 切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。如遇诈骗请立即拨打 110 报警。
\n >}\n {blocked\n ? \n : <>>}\n ;\n}\n\n// target_type is one of user/group/message only (verified against server/store/store.go's\n// AbuseReasons validation and im_http.go's normalizeIMAbuseTarget switch -- there is no\n// \"group_member\" variant server-side; reporting a group member uses target_type=user and simply\n// loses the \"which group\" context, which is a real backend limitation, not something fixable here).\nfunction ReportReasons({hashParams, onToast}) {\n const targetType = (hashParams && hashParams.target_type) || 'user';\n const targetName = (hashParams && hashParams.target_name) || '';\n const [reasons, setReasons] = React.useState(null);\n React.useEffect(() => {\n getAbuseReasons(targetType).then(data => setReasons(Array.isArray(data) ? data : (data && (data.reasons || data.items)) || []))\n .catch(err => { onToast(err.message || '加载失败'); setReasons([]); });\n }, [targetType]);\n const select = reason => navigate('abuse-report-details', {...hashParams, reason_code: reason.reason_code || reason.code, reason_label: reason.label, requires_description: reason.requires_description ? '1' : ''});\n {/* 'personal-center' made sense when contact-management-view.jsx's friend-settings page was the\n only entry point; tinode-web.jsx's handleReportTopic (block-and-report from inside a chat,\n which closes that chat before landing here) now also routes here, and for that origin\n 'personal-center' is a non-sequitur -- messages-home is a reasonable default for both. */}\n return \n {targetName ? account_circle举报{targetName}举报人身份不会向对方公开 : null}\n 请选择举报原因
\n {reasons === null ? 加载中…
: \n {reasons.map(reason => )}\n }\n ;\n}\n\nfunction ReportDetails({hashParams, tinode, onToast}) {\n const reasonLabel = (hashParams && hashParams.reason_label) || '';\n const reasonCode = (hashParams && hashParams.reason_code) || '';\n const requiresDescription = (hashParams && hashParams.requires_description) === '1';\n const [description, setDescription] = React.useState('');\n const [evidence, setEvidence] = React.useState([]);\n const [uploading, setUploading] = React.useState(false);\n const [submitting, setSubmitting] = React.useState(false);\n const [submitted, setSubmitted] = React.useState(null);\n const fileInputRef = React.useRef(null);\n\n const addEvidence = event => {\n const file = event.target.files && event.target.files[0];\n event.target.value = '';\n if (!file || !tinode) return;\n if (evidence.length >= 9) { onToast('最多上传 9 个证据文件'); return; }\n setUploading(true);\n uploadLargeFile(tinode, file).then(({fileId}) => setEvidence(items => items.concat({id: fileId, name: file.name})))\n .catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));\n };\n\n const submit = () => {\n if (requiresDescription && !description.trim()) { onToast('请补充问题描述'); return; }\n const target = {type: hashParams.target_type || 'user'};\n if (hashParams.target_type === 'message') { target.topic = hashParams.topic; target.seq = String(hashParams.seq); }\n else if (hashParams.target_type === 'group') { target.topic = hashParams.topic || hashParams.target_id; }\n else { target.user_id = hashParams.target_id; }\n setSubmitting(true);\n submitAbuseReport({target, reason_code: reasonCode, description: description.trim(), evidence_file_ids: evidence.map(e => e.id)})\n .then(result => setSubmitted(result))\n .catch(err => onToast(err.message || '提交失败'))\n .finally(() => setSubmitting(false));\n };\n\n return \n {submitted ?
task_alt举报已受理
我们会依据平台规则核实处理,举报人身份不会向被举报方公开。
- 受理编号
- {submitted.report_id}
\n :
\n 举报原因{reasonLabel}\n \n \n 证据材料图片或文件,最多 9 个{evidence.length}/9\n \n {evidence.map(file =>
image{file.name})}\n
\n
\n
\n \n \n }\n {!submitted ?
: null}\n
;\n}\n\n// category is REQUIRED server-side (POST /feedback rejects empty category) but the reference image\n// (意见反馈.png) shows no category-selection UI at all -- picking a sensible default silently would\n// mean every feedback submission gets misfiled under one bucket. A minimal category picker is added\n// here (using the real categories from GET /feedback/config, not the mock's invented four-item list)\n// since it's a real, meaningful, backend-required field -- same judgment call as Ch.6's \"my nickname\n// in this group\" editor: no reference image, but a real backend capability worth a minimal UI for.\n// `contact` (image has no input for it) is left unset -- it's optional server-side, so omitting it\n// doesn't break the request, and adding a field the design never showed would be inventing UI.\nfunction UserFeedback({tinode, onToast}) {\n // Fixed/parameterless config (max files, categories, max length, diagnostics notice) reached from a\n // single entry point (personal-center's \"意见反馈\" row) off the same hub whose own profile fetch is\n // already cached -- seeding both `config` and its derived default `category` together from the same\n // cached object avoids a flash where the category picker renders (gated on `config` alone) with\n // nothing selected yet.\n const cachedFeedbackConfig = readCachedData('feedback-config');\n const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);\n const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');\n const [content, setContent] = React.useState('');\n const [images, setImages] = React.useState([]);\n const [uploading, setUploading] = React.useState(false);\n const [diagnostics, setDiagnostics] = React.useState(false);\n const [submitting, setSubmitting] = React.useState(false);\n const [submitted, setSubmitted] = React.useState(null);\n const fileInputRef = React.useRef(null);\n const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;\n const maxLength = (config && config.max_length) || 300;\n\n React.useEffect(() => {\n getFeedbackConfig().then(data => {\n writeCachedData('feedback-config', data);\n setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');\n }).catch(() => {});\n }, []);\n\n const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};\n\n const addImage = event => {\n const file = event.target.files && event.target.files[0];\n event.target.value = '';\n if (!file || !tinode) return;\n if (images.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 张图片`); return; }\n setUploading(true);\n uploadLargeFile(tinode, file).then(({fileId}) => setImages(items => items.concat({id: fileId})))\n .catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));\n };\n\n const submit = () => {\n if (!content.trim()) { onToast('请填写反馈内容'); return; }\n setSubmitting(true);\n submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})\n .then(result => setSubmitted(result))\n .catch(err => onToast(err.message || '提交失败'))\n .finally(() => setSubmitting(false));\n };\n\n if (submitted) return mark_email_read反馈已受理
感谢你的反馈,我们会尽快查看并用于改进产品。
- 反馈编号
- {submitted.feedback_id}
;\n\n return ;\n}\n\nexport default function SecuritySupportView({view, hashParams, tinode}) {\n const [toast, setToast] = React.useState('');\n let content;\n if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = ;\n else if (view === 'abuse-report-reasons') content = ;\n else if (view === 'abuse-report-details') content = ;\n else content = ;\n return <>{content} setToast('')} />>;\n}\n","// Tinode config panel.\nimport React from 'react';\nimport { FormattedMessage } from 'react-intl';\n\nimport CheckBox from '../widgets/checkbox.jsx';\n\nimport HostSelector from '../widgets/host-selector.jsx';\n\nexport default class SettingsView extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.state = {\n transport: props.transport || 'def',\n serverAddress: props.serverAddress,\n secureConnection: props.secureConnection,\n };\n\n this.handleSubmit = this.handleSubmit.bind(this);\n this.handleTransportSelected = this.handleTransportSelected.bind(this);\n this.handleServerAddressChange = this.handleServerAddressChange.bind(this);\n this.handleToggleSecure = this.handleToggleSecure.bind(this);\n }\n\n handleSubmit(e) {\n e.preventDefault();\n this.props.onUpdate({\n transport: this.state.transport,\n serverAddress: this.state.serverAddress,\n secureConnection: this.state.secureConnection,\n });\n }\n\n handleTransportSelected(e) {\n this.setState({transport: e.currentTarget.value});\n }\n\n handleServerAddressChange(name) {\n this.setState({serverAddress: name});\n }\n\n handleToggleSecure(e) {\n this.setState({secureConnection: !this.state.secureConnection});\n }\n\n render() {\n const names = {def: \"default\", ws: \"websocket\", lp: \"long polling\"};\n const transportOptions = [];\n ['def', 'ws', 'lp'].forEach(item => {\n const id = 'transport-' + item;\n const name = names[item];\n transportOptions.push(\n \n \n \n \n );\n });\n\n return (\n \n );\n }\n};\n","import React, { Suspense } from 'react';\nimport { defineMessages, FormattedMessage, injectIntl } from 'react-intl';\n\nimport ErrorPanel from '../widgets/error-panel.jsx';\nimport LoadSpinner from '../widgets/load-spinner.jsx';\nimport SideNavbar from '../widgets/side-navbar.jsx';\nimport TopicCommonView from './topic-common-view.jsx';\nimport AuthOnboardingView from './auth-onboarding-view.jsx';\nimport MessageCenterView from './message-center-view.jsx';\nimport DirectMessageView from './direct-message-view.jsx';\nimport MessageComposerView, {MESSAGE_COMPOSER_VIEWS} from './message-composer-view.jsx';\nimport ContactManagementView, {CONTACT_MANAGEMENT_VIEWS} from './contact-management-view.jsx';\nimport GroupManagementView, {GROUP_MANAGEMENT_VIEWS} from './group-management-view.jsx';\nimport ContentLibraryView, {CONTENT_LIBRARY_VIEWS} from './content-library-view.jsx';\nimport CallExperienceView, {CALL_EXPERIENCE_VIEWS} from './call-experience-view.jsx';\nimport WorkplaceCollaborationView, {WORKPLACE_COLLABORATION_VIEWS} from './workplace-collaboration-view.jsx';\nimport PersonalSettingsView, {PERSONAL_SETTINGS_VIEWS} from './personal-settings-view.jsx';\nimport SecuritySupportView, {SECURITY_SUPPORT_VIEWS} from './security-support-view.jsx';\n\nconst AccountSettingsView = React.lazy(_ => import('./account-settings-view.jsx'));\nimport ContactsView from './contacts-view.jsx';\nconst CreateAccountView = React.lazy(_ => import('./create-account-view.jsx'));\nimport AccGeneralView from './acc-general-view.jsx';\nimport AccNotificationsView from './acc-notifications-view.jsx';\nconst AccSecurityView = React.lazy(_ => import('./acc-security-view.jsx'));\nimport AccSupportView from './acc-support-view.jsx';\nimport LoginView from './login-view.jsx';\nimport NewTopicView from './new-topic-view.jsx';\nconst PasswordResetView = React.lazy(_ => import('./password-reset-view.jsx'));\nimport SettingsView from './settings-view.jsx';\nimport ValidationView from './validation-view.jsx';\nconst WallpapersView = React.lazy(_ => import('./wallpapers.jsx'));\n\n// Panel titles for translation.\nconst messages = defineMessages({\n login: {\n id: 'sidepanel_title_login',\n description: 'Sidepanel title for LoginView.',\n defaultMessage: 'Sign In'\n },\n register: {\n id: 'sidepanel_title_register',\n description: 'Sidepanel title for CreateAccountView.',\n defaultMessage: 'Create Account'\n },\n settings: {\n id: 'sidepanel_title_settings',\n description: 'Sidepanel title for SettingsView.',\n defaultMessage: 'Settings'\n },\n edit: {\n id: 'sidepanel_title_account_settings',\n description: 'Sidepanel title for AccountSettingsView.',\n defaultMessage: 'Account Settings'\n },\n general: {\n id: 'panel_title_general',\n description: 'Title for TopicCommon.',\n defaultMessage: 'General'\n },\n security: {\n id: 'panel_title_security',\n description: 'Title for TopicSecirity and AccSecurity.',\n defaultMessage: 'Security'\n },\n crop: {\n id: 'panel_title_crop',\n description: 'Title for AvatarCropView.',\n defaultMessage: 'Drag to Adjust'\n },\n notif: {\n id: 'sidepanel_title_acc_notifications',\n description: 'Sidepanel title for AccNotificationsView.',\n defaultMessage: 'Notifications'\n },\n acc_general: {\n id: 'sidepanel_title_acc_general',\n description: 'Sidepanel title for AccGeneralView.',\n defaultMessage: 'General'\n },\n support: {\n id: 'sidepanel_title_acc_support',\n description: 'Sidepanel title for AccSupportView.',\n defaultMessage: 'Support'\n },\n newtpk: {\n id: 'sidepanel_title_newtpk',\n description: 'Sidepanel title for NewTopicView.',\n defaultMessage: 'Start New Chat'\n },\n cred: {\n id: 'sidepanel_title_cred',\n description: 'Sidepanel title for ValidationView.',\n defaultMessage: 'Confirm Credentials'\n },\n reset: {\n id: 'sidepanel_title_reset',\n description: 'Sidepanel title for PasswordResetView.',\n defaultMessage: 'Reset Password'\n },\n archive: {\n id: 'sidepanel_title_archive',\n description: 'Sidepanel title for ContactsView-Archive.',\n defaultMessage: 'Archived Chats'\n },\n blocked: {\n id: 'sidepanel_title_blocked',\n description: 'Sidepanel title for ContactsView-Blocked.',\n defaultMessage: 'Blocked Chats'\n },\n wallpapers: {\n id: 'wallpapers',\n description: 'Link or title for working with wallpapers.',\n defaultMessage: 'Wallpapers'\n }\n});\n\nclass SidepanelView extends React.PureComponent {\n constructor(props) {\n super(props);\n\n this.handleNewTopic = this.handleNewTopic.bind(this);\n }\n\n handleNewTopic() {\n this.props.onNavigate('newtpk');\n }\n\n render() {\n const {formatMessage} = this.props.intl;\n const view = this.props.state || (this.props.myUserId ? 'messages-home' : 'tenant');\n const chapterOneViews = ['tenant', 'login', 'enterprise-register', 'enterprise-feedback', 'register',\n 'reset', 'identity-check', 'realname', 'realname-verified', 'realname-reminder', 'independent-password'];\n\n if (chapterOneViews.includes(view)) {\n return ;\n }\n\n const chapterTwoViews = ['messages-home', 'messages-offline', 'messages-menu', 'message-actions', 'system-messages'];\n if (chapterTwoViews.includes(view)) {\n const fullPage = !this.props.myUserId || view !== 'messages-home';\n return \n \n
;\n }\n\n const chapterThreeViews = ['chat-inbound-request', 'chat-inbound-message', 'chat-outbound-request',\n 'chat-status', 'chat-friend', 'chat-media', 'chat-blocked', 'chat-deleted', 'chat-standard',\n 'chat-attachments', 'chat-mixed', 'chat-contact-card'];\n if (chapterThreeViews.includes(view)) {\n return \n \n
;\n }\n\n if (MESSAGE_COMPOSER_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (CONTACT_MANAGEMENT_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (GROUP_MANAGEMENT_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (CONTENT_LIBRARY_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (CALL_EXPERIENCE_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (WORKPLACE_COLLABORATION_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n if (PERSONAL_SETTINGS_VIEWS.includes(view)) {\n return ;\n }\n\n if (SECURITY_SUPPORT_VIEWS.includes(view)) {\n return \n \n
;\n }\n\n let title, avatar, badges;\n if (view == 'contacts') {\n title = this.props.title;\n avatar = this.props.avatar ? this.props.avatar : true;\n badges = this.props.trustedBadges;\n } else {\n title = formatMessage(messages[view]);\n avatar = false;\n badges = null;\n }\n\n let onCancel;\n if (['login', 'contacts'].indexOf(view) == -1) {\n onCancel = this.props.onCancel;\n }\n\n return (\n \n \n\n \n\n \n\n {view === 'login' ?\n :\n\n view === 'register' ?\n
}>\n \n :\n\n view === 'settings' ?\n :\n\n view === 'edit' ?\n