Page MenuHomePhabricator

No OneTemporary

diff --git a/landing/keyservers.react.js b/landing/keyservers.react.js
index 157aa8c43..cf4409094 100644
--- a/landing/keyservers.react.js
+++ b/landing/keyservers.react.js
@@ -1,147 +1,147 @@
// @flow
import { create } from '@lottiefiles/lottie-interactivity';
import * as React from 'react';
import { useIsomorphicLayoutEffect } from 'lib/hooks/isomorphic-layout-effect.react.js';
import { assetsCacheURLPrefix } from './asset-meta-data.js';
import css from './keyservers.css';
import ReadDocsButton from './read-docs-btn.react.js';
import StarBackground from './star-background.react.js';
function Keyservers(): React.Node {
React.useEffect(() => {
import('@lottiefiles/lottie-player');
}, []);
const onEyeIllustrationLoad = React.useCallback(() => {
create({
mode: 'scroll',
player: '#eye-illustration',
actions: [
{
visibility: [0, 1],
type: 'seek',
frames: [0, 720],
},
],
});
}, []);
const onCloudIllustrationLoad = React.useCallback(() => {
create({
mode: 'scroll',
player: '#cloud-illustration',
actions: [
{
visibility: [0, 0.2],
type: 'stop',
frames: [0],
},
{
visibility: [0.2, 1],
type: 'seek',
frames: [0, 300],
},
],
});
}, []);
const [eyeNode, setEyeNode] = React.useState(null);
useIsomorphicLayoutEffect(() => {
if (!eyeNode) {
return undefined;
}
eyeNode.addEventListener('load', onEyeIllustrationLoad);
return () => eyeNode.removeEventListener('load', onEyeIllustrationLoad);
}, [eyeNode, onEyeIllustrationLoad]);
const [cloudNode, setCloudNode] = React.useState(null);
useIsomorphicLayoutEffect(() => {
if (!cloudNode) {
return undefined;
}
cloudNode.addEventListener('load', onCloudIllustrationLoad);
return () => cloudNode.removeEventListener('load', onCloudIllustrationLoad);
}, [cloudNode, onCloudIllustrationLoad]);
return (
<div>
<StarBackground />
<div className={css.body_grid}>
<div className={`${css.hero_image} ${css.starting_section}`}>
<lottie-player
id="eye-illustration"
ref={setEyeNode}
mode="normal"
src={`${assetsCacheURLPrefix}/animated_eye.json`}
speed={1}
/>
</div>
<div className={`${css.hero_copy} ${css.section}`}>
<h1 className={css.mono}>
Reclaim your
<span className={css.purple_accent}> digital&nbsp;identity.</span>
</h1>
<p>
The Internet is broken today. Private user data is owned by
mega-corporations and farmed for their benefit.
</p>
<p>
E2E encryption has the potential to change this equation. But
- it&apos;s constrained by a crucial limitation.
+ it&rsquo;s constrained by a crucial limitation.
</p>
</div>
<div className={`${css.server_image} ${css.starting_section}`}>
<lottie-player
id="cloud-illustration"
ref={setCloudNode}
mode="normal"
src={`${assetsCacheURLPrefix}/animated_cloud.json`}
speed={1}
/>
</div>
<div className={`${css.server_copy} ${css.section}`}>
<h1 className={css.mono}>Apps need servers.</h1>
<p>
Sophisticated applications rely on servers to do things that your
- devices simply can&apos;t.
+ devices simply can&rsquo;t.
</p>
<p>
- That&apos;s why E2E encryption only works for simple chat apps
- today. There&apos;s no way to build a robust server layer that has
+ That&rsquo;s why E2E encryption only works for simple chat apps
+ today. There&rsquo;s no way to build a robust server layer that has
access to your data without leaking that data to corporations.
</p>
</div>
<div className={css.keyserver_company}>
<h1>
Comm
<span className={css.mono}>
{' '}
is the <span className={css.purple_accent}>keyserver</span>{' '}
company.
</span>
</h1>
</div>
<div className={css.keyserver_copy}>
<p>In the future, people have their own servers.</p>
<p>
Your keyserver is the home of your digital identity. It owns your
- private keys and your personal data. It&apos;s your password
+ private keys and your personal data. It&rsquo;s your password
manager, your crypto bank, your digital surrogate, and your second
brain.
</p>
</div>
<div className={css.read_the_docs}>
<ReadDocsButton />
</div>
</div>
</div>
);
}
export default Keyservers;
diff --git a/landing/privacy.react.js b/landing/privacy.react.js
index 75f9c27f9..3fdb4ef6a 100644
--- a/landing/privacy.react.js
+++ b/landing/privacy.react.js
@@ -1,428 +1,428 @@
// @flow
import * as React from 'react';
import css from './legal.css';
function Privacy(): React.Node {
return (
<div className={css.legal_container}>
<h1>Privacy Policy</h1>
<p>
Effective date: <strong>February 2, 2023</strong>
</p>
<h2>Introduction</h2>
<p>
We built Comm as a privacy-focused alternative to the cloud-based
community chat apps that exist today. In order to protect the privacy of
user content, communities on Comm are hosted on private keyservers, and
messages transmitted through the platform are encrypted, preventing us
from being able to access their contents. While Comm does collect a
minimal amount of information as necessary to power and maintain the
Services, Comm will never be able to access or view the substance of
your Content. This Privacy Policy describes the limited ways in which
Comm uses information in connection with its Services.
</p>
<p>
<strong>
‌By using or accessing our Services, you acknowledge that you accept
the practices and policies outlined below.
</strong>
</p>
<h2>Table of Contents</h2>
<ul>
<li>
<strong>Information We Collect</strong>
</li>
<li>
<strong>Public Blockchain Information</strong>
</li>
<li>
<strong>How We May Share Your Information</strong>
</li>
<li>
<strong>Retention of Information</strong>
</li>
<li>
<strong>Your Rights in the Personal Data You Provide to Us</strong>
</li>
<li>
<strong>Deleting Your Data</strong>
</li>
<li>
<strong>Changes to this Privacy Policy</strong>
</li>
<li>
<strong>Terms of Use</strong>
</li>
<li>
<strong>Contact Information</strong>
</li>
</ul>
<h2>Information We Collect</h2>
<p>
We collect the following categories of information, some of which might
constitute personal information:
</p>
<ol>
<li>
<strong>Account Information.</strong> In order to use Comm, you will
be required to create an account, and in doing so, may be required to
provide a username and password to us. We only store salted and hashed
versions of your password – we never store the plaintext version. No
other information is required to create an account and we do not want
you to provide any other information.
</li>
<li>
<strong>Blockchain Account Information.</strong> You may instead
choose to log into Comm using a third-party blockchain provider (such
as, Sign-In with Ethereum or similar functionality), in which case you
will be required to provide your blockchain address to us. The only
information we collect as part of this process is information that is
already publicly available.
</li>
<li>
<strong>Backup Service.</strong> You have the option to back up your
Content on Comm’s hosted servers, allowing you to recover any
backed-up Content. If you do not opt out of the backup service, your
Content will be stored in our automated backup service.{' '}
<strong>
Your Content will always remain end-to-end encrypted, even when
stored through our backup service.
</strong>{' '}
You may recover your backed-up Content through an automatic recovery
process available through your account. If you request your Content,
Comm will verify your identity cryptographically to verify that the
Content you are requesting belongs to you; however, such request will
not allow Comm to collect or view any information that is not already
outlined in this Privacy Policy and your backed-up Content will only
be associated with your account. The backup service only allows Comm
to collect encrypted versions of your Content – at no point will Comm
have the ability to access or view the substance of your Content nor
will Comm ever have the ability to decrypt your Content collected
through the backup service. You may opt-out of this backup service at
any time by doing so within the app.
</li>
<li>
<strong>Updates About the Services.</strong> If you choose to receive
updates on Comm’s progress, you will be required to provide your email
address to us. If you choose to send any emails to us in response to
such updates or otherwise, we may collect the content of such emails.
These email addresses, and any emails received from you, will not be
associated with any accounts, and will not be used for any purpose
other than to provide updates on Comm and to otherwise respond to you.
</li>
<li>
<strong>Time Zone Detection.</strong> When responding to a web
request, we use the requester’s IP address in order to determine which
time zone to render timestamps in. We do not store these IP addresses
or associate them with specific accounts.
</li>
<li>
<strong>Security, Fraud and Abuse.</strong> In order to detect and
prevent abuse of our Services and cyberattacks, we keep track of
request metadata, which includes requests made by IP addresses as well
as the frequency of those requests. This data is only stored on a
short-term basis and is never associated with specific accounts, even
if the requests themselves originate from or are associated with
specific accounts. Additionally, we may view and block certain IP
address ranges as necessary to comply with applicable United States
export control laws and regulations.
</li>
<li>
<strong>Optimizing the Services.</strong> In order to optimize the
Services provided to you, we keep track of which version of Comm’s
code you are running as well as the platform that you are using to run
Comm’s code (e.g., iOS, Android, web server, keyserver). We only
collect this information to provide the Services to you, and are not
able to access or view the substance of your Content in connection
with such collection.
</li>
<li>
<strong>Crash Reports.</strong> If you choose to send us a crash
report, we may collect data from such reports for purposes of
debugging and system maintenance. These reports contain operational
information such as telemetry data (e.g., information with respect to
the recent connection between the client and server), metadata (e.g.,
time stamps from messages sent in a conversation or chat), and device
data (e.g., your device’s operating system) but never contain the
content of your messages.
</li>
<li>
<strong>Push Notifications.</strong> If you choose to allow push
notifications to your device, your device’s operating system’s
provider will know that you are using the Services and may be able to
see the Content you transmit using the Services. We only collect push
tokens required to send you such notifications and these tokens do not
permit us to access or view your Content.
</li>
<li>
<strong>Contact List.</strong> If you allow us to do so, we can
discover which contacts in your address book are Comm users by using
technology designed to protect the privacy of you and your contacts.
If you opt to discover other Comm users in your contact list, phone
numbers from the contacts on your device, as well as your own phone
number, will be hashed and transmitted to Comm in order to match you
and your friends on the Services. Since this contact information is
hashed, Comm cannot view or access the plaintext version.
</li>
<li>
<strong>Cookies.</strong> We only use a single cookie per user in
order to authenticate a user as being logged in to Comm. Most browsers
allow you to decide whether to accept these cookies and whether to
remove any cookies already on your device. If you disable these
cookies, you will not be able to stay logged into Comm.
</li>
<li>
<strong>Bots.</strong> We may communicate with you through the
Services by using bots, and we will have access to any information you
voluntarily provide in response to those bots. We only collect this
information to provide the Services to you, and are not able to access
or view the substance of any other Content in connection with such
collection.
</li>
<li>
<strong>Content.</strong> Other Comm users may have access to, and
have the ability to store, all or some of the Content owned by you.{' '}
<strong>
However, as this Content is end-to-end encrypted, we have no ability
to access or view the substance of your Content.
</strong>{' '}
For more information on how Comm works, please{' '}
<a href="https://www.notion.so/How-Comm-works-d6217941db7c4237b9d08b427aef3234">
click here
</a>
.
</li>
<li>
<strong>Anonymized Data.</strong> We may create aggregated,
de-identified or anonymized data from the information we collect from
you, including by removing information that makes the data personally
identifiable to you. We may use such aggregated, de-identified or
anonymized data for our own lawful business purposes, including to
analyze, build and improve the Services and promote our business,
provided that we will not use such data in a manner that could
identify you.
</li>
</ol>
<h2>Public Blockchain Information</h2>
<p>
Information posted on a blockchain is publicly available and auditable.
When you and other users use the Services, Comm’s applications will
automatically make calls to third-party blockchain providers using
users’ blockchain addresses (including yours) to access publicly
available information about those blockchain addresses (e.g., ENS name,
ENS avatar). Such publicly available information will then be displayed
through the Services to you and/or other Comm users. This information is
not shared or stored by Comm, and the only information accessible and
displayed is information that is publicly available on the blockchain.
</p>
<p>
Additionally, if you choose to use Comm’s applications, third-party
blockchain providers may also have access to your IP address. While your
IP address is not shared by Comm, it is automatically accessible by such
providers through your use of Comm’s applications, regardless of whether
you intend to make such information available. This access is required
in order to allow Comm users to make calls to third-party blockchain
providers, and Comm has no control over what such third-party blockchain
provider may do with your IP address.
</p>
<h2>How We May Share Your Information</h2>
<p>
We may share the information we collect with third parties for the
reasons listed below.{' '}
<strong>
However, your Content will always remain end-to-end encrypted, even
when stored in the backup service, and Comm has no ability to decrypt
that information for any third party.{' '}
</strong>
Moreover, we will <strong>never</strong> sell, rent or monetize your
information.
</p>
<ol>
<li>
To fulfill our legal obligations under applicable law, regulation,
court order or other legal process.
</li>
<li>
To protect the rights, property or safety of you, Comm Technologies or
another party as required or permitted by law.
</li>
<li>To enforce any agreements with you.</li>
<li>
Pursuant to a merger, acquisition, bankruptcy or other transaction in
which that third party assumes control of our business (in whole or in
part).
</li>
</ol>
<p>
While sharing your email address is entirely optional, if you do choose
to share it, we may share that email address, and the contents of any
email sent by us or you, with our third-party service providers who may
assist us in providing updates to you. Such information would be shared
with such third-party service providers for the sole purpose of
providing you updates on Comm, and will never be associated with your
account or sold, rented or otherwise monetized.
</p>
<p>
Additionally, Comm uses third-party hosting providers to host its
central cloud servers, therefore we may share the information we collect
with such hosting providers in order for them to host this information.
This information will be shared with such hosting providers for the sole
purpose of providing the Services to you, and your Content will always
remain end-to-end encrypted when shared with such hosting providers,
even when stored in the backup service.
</p>
<h2>Retention of Information</h2>
<p>
For any of your personal information that we collect, we retain such
personal information for as long as you have an open account with us or,
only with respect to the association between your account and public
key, as otherwise necessary to provide our Services.
</p>
<h1>Your Rights in the Personal Data You Provide to Us</h1>
<h2>Your Rights</h2>
<p>
Under applicable data protection legislation, in certain circumstances,
you have rights concerning your personal data. You have a right to:
</p>
<ol>
<li>
<strong>Access.</strong> You can request more information about the
personal data we hold about you and request a copy of such personal
data.
</li>
<li>
<strong>‌Rectification.</strong> You can correct any inaccurate or
incomplete personal data we are holding about you.
</li>
<li>
<strong>Erasure.</strong> You can request that we erase some or all of
your personal data from our systems.
</li>
<li>
<strong>Withdrawal of Consent.</strong> You have the right to withdraw
your consent to our processing of your personal data at any time.
Please note, however, that if you exercise this right, you may have to
then provide express consent on a case-by-case basis for the use or
disclosure of certain of your personal data, if such use or disclosure
is necessary to enable you to utilize some or all of our Services.
</li>
<li>
<strong>Portability.</strong> You can ask for a copy of your personal
data in a machine-readable format and can also request that we
transmit the data to another data controller where technically
feasible.
</li>
<li>
<strong>Objection.</strong> You can contact us to let us know that you
object to the further use of your personal data.
</li>
<li>
<strong>Restriction of Processing.</strong> You can ask us to restrict
further processing of your personal data.
</li>
<li>
<strong>Right to File Complaint.</strong> You have the right to lodge
a complaint with national data protection authorities regarding our
processing of your personal data.
</li>
</ol>
<h2>Exercising Your Rights</h2>
<p>
If you wish to exercise any of these rights, please contact us using the
details below.
</p>
<h2>Deleting Your Data</h2>
<p>
If you would like to delete your account, you can do this either by
deleting your account within the app, or by contacting us using the
information below. Deleting your account removes all personal
information that you provided to us.
</p>
<p>
Termination of your account may or may not result in destruction of
Content associated with your account, and other Comm users may continue
to have access to and control over your Content.
</p>
<p>
If you would like to delete any backed-up Content from our backup
service, you may do so at any time by making a request within the app.
</p>
<h2>Changes to this Privacy Policy</h2>
<p>
We may update this Privacy Policy from time to time. If you use the
Services after any changes to the Privacy Policy have been posted, that
means you agree to all of the changes. Use of information we collect is
subject to the Privacy Policy in effect at the time such information is
collected.
</p>
<h2>Terms of Use</h2>
<p>
- Remember that your use of Comm Technologies&apos; Services is at all
+ Remember that your use of Comm Technologies&rsquo; Services is at all
times subject to our <a href="https://comm.app/terms">Terms of Use</a>,
which incorporates this Privacy Policy. Any terms we use in this Policy
without defining them have the definitions given to them in the Terms of
Use.
</p>
<h2>Contact Information</h2>
<p>
If you have any questions or comments about this Privacy Policy, the
ways in which we collect and use your information or your choices and
rights regarding such collection and use, please do not hesitate to
contact us at:
</p>
<ul>
<li>
Website: <a href="https://comm.app">https://comm.app</a>
</li>
<li>
Email: <a href="mailto:support@comm.app">support@comm.app</a>
</li>
<li>Phone: +1 (332) 203-4023</li>
<li>
Address: Comm Technologies, Inc. / 203 Rivington St. Apt 1K / New
York, NY 10002
</li>
</ul>
</div>
);
}
export default Privacy;
diff --git a/landing/qr.react.js b/landing/qr.react.js
index 3e7fdbcf0..8b0c1585d 100644
--- a/landing/qr.react.js
+++ b/landing/qr.react.js
@@ -1,68 +1,68 @@
// @flow
import {
faTwitter,
faGithub,
faAppStoreIos,
} from '@fortawesome/free-brands-svg-icons';
import { faBook, faHome, faBriefcase } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as React from 'react';
import stores from 'lib/facts/stores.js';
import css from './qr.css';
function QR(): React.Node {
const iconStyle = React.useMemo(() => ({ marginRight: '20px' }), []);
return (
<div className={css.body}>
<h1>Comm</h1>
<a href="https://twitter.com/commdotapp">
<div className={`${css.qr_link} ${css.qr_link_twitter}`}>
<FontAwesomeIcon icon={faTwitter} size="lg" style={iconStyle} />
<h3>@CommDotApp</h3>
</div>
</a>
<a href="https://github.com/CommE2E/comm">
<div className={`${css.qr_link} ${css.qr_link_github}`}>
<FontAwesomeIcon icon={faGithub} size="lg" style={iconStyle} />
<h3>CommE2E/comm</h3>
</div>
</a>
<a href={stores.appStoreUrl}>
<div className={`${css.qr_link} ${css.qr_link_appstore}`}>
<FontAwesomeIcon icon={faAppStoreIos} size="lg" style={iconStyle} />
<h3>App Store (pre-alpha)</h3>
</div>
</a>
<a href="https://comm.app">
<div className={`${css.qr_link} ${css.qr_link_comm}`}>
<FontAwesomeIcon icon={faHome} size="lg" style={iconStyle} />
<h3>Homepage</h3>
</div>
</a>
<a href="https://www.notion.so/Comm-4ec7bbc1398442ce9add1d7953a6c584">
<div className={`${css.qr_link} ${css.qr_link_comm}`}>
<FontAwesomeIcon icon={faBook} size="lg" style={iconStyle} />
<h3>Technical docs</h3>
</div>
</a>
<a href="https://commapp.notion.site/commapp/Comm-is-hiring-db097b0d63eb4695b32f8988c8e640fd">
<div className={`${css.qr_link} ${css.qr_link_comm}`}>
<FontAwesomeIcon icon={faBriefcase} size="lg" style={iconStyle} />
- <h3>We&apos;re hiring!</h3>
+ <h3>We&rsquo;re hiring!</h3>
</div>
</a>
</div>
);
}
export default QR;
diff --git a/landing/terms.react.js b/landing/terms.react.js
index 49d015e18..7c442c0dd 100644
--- a/landing/terms.react.js
+++ b/landing/terms.react.js
@@ -1,281 +1,281 @@
// @flow
import * as React from 'react';
import css from './legal.css';
function Terms(): React.Node {
return (
<div className={css.legal_container}>
<h1>Terms of Use</h1>
<p>
Effective date: <strong>June 29, 2021</strong>
</p>
<p>
These Terms of Use (the “Terms”) are a binding contract between you and{' '}
<strong>COMM TECHNOLOGIES, INC.</strong> (“Comm Technologies,” “we” and
“us”) and govern your use of our website, products, services and
applications (the “Services”). Your use of the Services in any way means
that you agree to all of these Terms, and these Terms will remain in
effect while you use the Services. These Terms include the provisions in
this document as well as those in the{' '}
<a href="https://comm.app/privacy">Privacy Policy</a>.
</p>
<h2>Will these Terms ever change?</h2>
<p>
We may change the Terms at any time, but if we do, we will place a
notice on our site located at{' '}
<a href="https://comm.app">https://comm.app</a>. If you don’t agree with
the new Terms, you are free to reject them; unfortunately, that means
you will no longer be able to use the Services. If you use the Services
in any way after a change to the Terms is effective, that means you
agree to all of the changes.
</p>
<h2>What about my privacy?</h2>
<p>
For the current Comm Technologies Privacy Policy, please{' '}
<a href="https://comm.app/privacy">click here</a>.
</p>
<h2>Minimum Age</h2>
<p>
You must be at least 13 years old to use our Services. The minimum age
to use our services without parental approval may be higher in your home
country.
</p>
<h2>What are the basics of using Comm?</h2>
<p>
In order to access Comm, you are required to sign up for an account and
select a user name and password. You will not share your user name,
account or password with anyone, and you must protect the security of
your user name, account, password and any other access tools or
credentials. You’re responsible for any activity associated with your
user name and account.
</p>
<h2>Who will have access to my Content?</h2>
<p>
<strong>
Comm Technologies does not have the ability to access, view or control
anything you post, upload, share, store, or otherwise provide through
the Services (your “Content”).
</strong>{' '}
However, other Comm users may have access to and control over your
Content. For more information on how Comm works, please{' '}
<a href="https://www.notion.so/How-Comm-works-d6217941db7c4237b9d08b427aef3234">
click here
</a>
.
</p>
<h2>Are there restrictions in how I can use the Services?</h2>
<p>
You agree that you will not provide or contribute anything to the
Services, or otherwise use or interact with the Services, in a manner
that: (a) infringes or violates the right of Comm Technologies, or our
users, or others, including intellectual property or other proprietary
rights; or (b) violates any law or regulation, including without
limitation, any applicable export control laws, privacy laws or any
other purpose not reasonably intended by Comm Technologies.
</p>
<h2>What are my rights in the Services?</h2>
<p>
The Content displayed or performed or available on or through the
Services may be protected by copyright and/or other intellectual
property laws. You promise to abide by all copyright notices, trademark
rules, information, and restrictions contained in any Content you access
through the Services, and you won’t use, copy, reproduce, modify,
translate, publish, broadcast, transmit, distribute, perform, upload,
display, license, sell, commercialize or otherwise exploit for any
purpose any Content not owned by you, (i) without the prior consent of
the owner of that Content or (ii) in a way that violates someone else’s
- (including Comm Technologies&apos;) rights. You further agree to comply
+ (including Comm Technologies&rsquo;) rights. You further agree to comply
with all applicable open source licenses.
</p>
<p>
Subject to these Terms, we grant each user of the Services a worldwide,
non-exclusive, non-sublicensable and non-transferable license to use the
Services.
</p>
<h2>Who is responsible for what is contributed to the Services?</h2>
<p>
Any information or Content posted or transmitted through the Services is
the sole responsibility of the person from whom such Content originated,
and you access all such information and Content at your own risk, and we
aren’t liable for any errors or omissions in that information or Content
or for any damages or loss you might suffer in connection with it.
</p>
<h2>
What if I see something on the Services that infringes my copyright?
</h2>
<p>
Comm Technologies does not host and has no access to or control over any
Content provided through the Services and has no ability to remove any
such Content. If you have any further questions, please contact{' '}
<a href="mailto:support@comm.app">support@comm.app</a>.
</p>
<h2>Who is responsible for third-party services?</h2>
<p>
The Services may contain links or connections to third-party websites or
services that are not owned or controlled by Comm Technologies. When you
access third-party websites or use third-party services, you accept that
there are risks in doing so, and that Comm Technologies is not
responsible for such risks. We encourage you to be aware when you leave
the Services and to read the terms and conditions and privacy policy of
each third-party website or service that you visit or utilize.
</p>
<h2>Will Comm Technologies ever change the Services?</h2>
<p>
We’re always trying to improve our Services, so they may change over
time. We may suspend or discontinue any part of the Services, or we may
introduce new features or impose limits on certain features or restrict
access to parts or all of the Services.
</p>
<h2>What if I want to stop using the Services?</h2>
<p>
You’re free to do that at any time, either by deleting your account
within the app, or by contacting us at{' '}
<a href="mailto:support@comm.app">support@comm.app</a>; please refer to
our <a href="https://comm.app/privacy">Privacy Policy</a>, as well as
the licenses above, to understand how we treat information you provide
to us after you have stopped using our Services. Termination of your
account may or may not result in destruction of Content associated with
your account, and other Comm users may continue to have access to and
control over your Content.
</p>
<p>
Comm Technologies is also free to terminate (or suspend access to) your
use of the Services or your account for any reason at our discretion,
including your breach of these Terms. Comm Technologies has the sole
right to decide whether you are in violation of any of the restrictions
set forth in these Terms.
</p>
<p>
Provisions that, by their nature, should survive termination of these
Terms shall survive termination. By way of example, all of the following
will survive termination: any limitations on our liability, any terms
regarding ownership or intellectual property rights, and terms regarding
disputes between us.
</p>
<h2>What else do I need to know?</h2>
<h2>Warranty Disclaimer.</h2>
<p>
Comm Technologies and its licensors, suppliers, partners, parent,
subsidiaries or affiliated entities, and each of their respective
officers, directors, members, employees, consultants, contract
employees, representatives and agents, and each of their respective
successors and assigns (Comm Technologies and all such parties together,
the “Comm Technologies Parties”) make no representations or warranties
concerning the Services, including without limitation regarding any
content contained in or accessed through the Services, and the Comm
Technologies Parties will not be responsible or liable for the accuracy,
copyright compliance, legality, or decency of material contained in or
accessed through the Services or any claims, actions, suits procedures,
costs, expenses, damages or liabilities arising out of use of, or in any
way related to your participation in, the Services. The Comm
Technologies Parties make no representations or warranties regarding
suggestions or recommendations of services or products offered or
purchased through or in connection with the Services. THE SERVICES AND
CONTENT ARE PROVIDED BY COMM TECHNOLOGIES (AND ITS LICENSORS AND
SUPPLIERS) ON AN “AS-IS” BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER
EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, IMPLIED WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON- INFRINGEMENT, OR
THAT USE OF THE SERVICES WILL BE UNINTERRUPTED OR ERROR-FREE. SOME
STATES DO NOT ALLOW LIMITATIONS ON HOW LONG AN IMPLIED WARRANTY LASTS,
SO THE ABOVE LIMITATIONS MAY NOT APPLY TO YOU. WE DO NOT CONTROL, AND
ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR
SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION
(INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES.
</p>
<h2>Limitation of Liability.</h2>
<p>
TO THE FULLEST EXTENT ALLOWED BY APPLICABLE LAW, UNDER NO CIRCUMSTANCES
AND UNDER NO LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, TORT,
CONTRACT, STRICT LIABILITY, OR OTHERWISE) SHALL ANY OF THE COMM
TECHNOLOGIES PARTIES BE LIABLE TO YOU OR TO ANY OTHER PERSON FOR (A) ANY
INDIRECT, SPECIAL, INCIDENTAL, PUNITIVE OR CONSEQUENTIAL DAMAGES OF ANY
KIND, INCLUDING DAMAGES FOR LOST PROFITS, BUSINESS INTERRUPTION, LOSS OF
DATA, LOSS OF GOODWILL, WORK STOPPAGE, ACCURACY OF RESULTS, OR COMPUTER
FAILURE OR MALFUNCTION, (B) ANY SUBSTITUTE GOODS, SERVICES OR
TECHNOLOGY, (C) ANY AMOUNT, IN THE AGGREGATE, IN EXCESS OF THE GREATER
OF (I) ONE-HUNDRED ($100) DOLLARS OR (II) THE AMOUNTS PAID AND/OR
PAYABLE BY YOU TO COMM TECHNOLOGIES THE IN CONNECTION WITH THE SERVICES
IN THE TWELVE (12) MONTH PERIOD PRECEDING THIS APPLICABLE CLAIM OR (D)
ANY MATTER BEYOND OUR REASONABLE CONTROL. SOME STATES DO NOT ALLOW THE
EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL OR CERTAIN OTHER
DAMAGES, SO ABOVE LIMITATION AND EXCLUSIONS MAY NOT APPLY TO YOU.
</p>
<h2>Assignment.</h2>
<p>
You may not assign, delegate or transfer these Terms or your rights or
obligations hereunder, or your Services account, in any way (by
- operation of law or otherwise) without Comm Technologies&apos; prior
+ operation of law or otherwise) without Comm Technologies&rsquo; prior
written consent. We may transfer, assign, or delegate these Terms and
our rights and obligations without consent.
</p>
<h2>Dispute Resolution.</h2>
<p>
These Terms are governed by and will be construed under the laws of the
State of New York, without regard to the conflict of laws provisions
thereof. Any legal action or proceeding relating to these Terms shall be
brought exclusively in the state or federal courts located in New York
County, New York, and each party consents to the jurisdiction thereof.
</p>
<h2>Miscellaneous</h2>
<p>
The failure of either you or us to exercise, in any way, any right
herein shall not be deemed a waiver of any further rights hereunder. If
any provision of these Terms are found to be unenforceable or invalid,
that provision will be limited or eliminated, to the minimum extent
necessary, so that these Terms shall otherwise remain in full force and
effect and enforceable. You and Comm Technologies agree that these Terms
are the complete and exclusive statement of the mutual understanding
between you and Comm Technologies, and that these Terms supersede and
cancel all previous written and oral agreements, communications and
other understandings relating to the subject matter of these Terms. You
hereby acknowledge and agree that you are not an employee, agent,
partner, or joint venture of Comm Technologies, and you do not have any
authority of any kind to bind Comm Technologies in any respect
whatsoever.
</p>
</div>
);
}
export default Terms;
diff --git a/native/account/registration/connect-ethereum.react.js b/native/account/registration/connect-ethereum.react.js
index 4302f3b7f..4342e25dc 100644
--- a/native/account/registration/connect-ethereum.react.js
+++ b/native/account/registration/connect-ethereum.react.js
@@ -1,219 +1,219 @@
// @flow
import * as React from 'react';
import { Text, View } from 'react-native';
import {
exactSearchUser,
exactSearchUserActionTypes,
} from 'lib/actions/user-actions.js';
import type { SIWEResult } from 'lib/types/siwe-types.js';
import {
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import RegistrationButtonContainer from './registration-button-container.react.js';
import RegistrationButton from './registration-button.react.js';
import RegistrationContainer from './registration-container.react.js';
import RegistrationContentContainer from './registration-content-container.react.js';
import type { RegistrationNavigationProp } from './registration-navigator.react.js';
import type { CoolOrNerdMode } from './registration-types.js';
import {
type NavigationRoute,
ExistingEthereumAccountRouteName,
} from '../../navigation/route-names.js';
import { useStyles } from '../../themes/colors.js';
import EthereumLogoDark from '../../vectors/ethereum-logo-dark.react.js';
import SIWEPanel from '../siwe-panel.react.js';
export type ConnectEthereumParams = {
+userSelections: {
+coolOrNerdMode: CoolOrNerdMode,
+keyserverUsername: string,
},
};
type PanelState = 'closed' | 'opening' | 'open' | 'closing';
type Props = {
+navigation: RegistrationNavigationProp<'ConnectEthereum'>,
+route: NavigationRoute<'ConnectEthereum'>,
};
function ConnectEthereum(props: Props): React.Node {
const isNerdMode =
props.route.params.userSelections.coolOrNerdMode === 'nerd';
const styles = useStyles(unboundStyles);
let body;
if (!isNerdMode) {
body = (
<Text style={styles.body}>
Connecting your Ethereum wallet allows you to use your ENS name and
- avatar in the app. You&apos;ll also be able to log in with your wallet
+ avatar in the app. You&rsquo;ll also be able to log in with your wallet
instead of a password.
</Text>
);
} else {
body = (
<>
<Text style={styles.body}>
Connecting your Ethereum wallet has three benefits:
</Text>
<View style={styles.list}>
<View style={styles.listItem}>
<Text style={styles.listItemNumber}>{'1. '}</Text>
<Text style={styles.listItemContent}>
Your peers will be able to cryptographically verify that your Comm
account is associated with your Ethereum wallet.
</Text>
</View>
<View style={styles.listItem}>
<Text style={styles.listItemNumber}>{'2. '}</Text>
<Text style={styles.listItemContent}>
- You&apos;ll be able to use your ENS name and avatar in the app.
+ You&rsquo;ll be able to use your ENS name and avatar in the app.
</Text>
</View>
<View style={styles.listItem}>
<Text style={styles.listItemNumber}>{'3. '}</Text>
<Text style={styles.listItemContent}>
You can choose to skip setting a password, and to log in with your
Ethereum wallet instead.
</Text>
</View>
</View>
</>
);
}
const [panelState, setPanelState] = React.useState<PanelState>('closed');
const openPanel = React.useCallback(() => {
setPanelState('opening');
}, []);
const onPanelClosed = React.useCallback(() => {
setPanelState('closed');
}, []);
const onPanelClosing = React.useCallback(() => {
setPanelState('closing');
}, []);
const siwePanelSetLoading = React.useCallback(
(loading: boolean) => {
if (panelState === 'closing' || panelState === 'closed') {
return;
}
setPanelState(loading ? 'opening' : 'open');
},
[panelState],
);
const onSkip = React.useCallback(() => {
// show username selection screen
}, []);
const exactSearchUserCall = useServerCall(exactSearchUser);
const dispatchActionPromise = useDispatchActionPromise();
const { navigate } = props.navigation;
const onSuccessfulWalletSignature = React.useCallback(
async (result: SIWEResult) => {
const searchPromise = exactSearchUserCall(result.address);
dispatchActionPromise(exactSearchUserActionTypes, searchPromise);
const { userInfo } = await searchPromise;
if (userInfo) {
navigate<'ExistingEthereumAccount'>({
name: ExistingEthereumAccountRouteName,
params: result,
});
} else {
// show avatar selection screen
}
},
[exactSearchUserCall, dispatchActionPromise, navigate],
);
let siwePanel;
if (panelState !== 'closed') {
siwePanel = (
<SIWEPanel
onClosing={onPanelClosing}
onClosed={onPanelClosed}
closing={panelState === 'closing'}
onSuccessfulWalletSignature={onSuccessfulWalletSignature}
setLoading={siwePanelSetLoading}
/>
);
}
return (
<>
<RegistrationContainer>
<RegistrationContentContainer style={styles.scrollViewContentContainer}>
<Text style={styles.header}>
Do you want to connect an Ethereum wallet?
</Text>
{body}
<View style={styles.ethereumLogoContainer}>
<EthereumLogoDark />
</View>
</RegistrationContentContainer>
<RegistrationButtonContainer>
<RegistrationButton
onPress={openPanel}
label="Connect Ethereum wallet"
variant={panelState === 'opening' ? 'loading' : 'enabled'}
/>
<RegistrationButton
onPress={onSkip}
label="Do not connect"
variant="outline"
/>
</RegistrationButtonContainer>
</RegistrationContainer>
{siwePanel}
</>
);
}
const unboundStyles = {
scrollViewContentContainer: {
flexGrow: 1,
},
header: {
fontSize: 24,
color: 'panelForegroundLabel',
paddingBottom: 16,
},
body: {
fontSize: 15,
lineHeight: 20,
color: 'panelForegroundSecondaryLabel',
paddingBottom: 16,
},
ethereumLogoContainer: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
},
list: {
paddingBottom: 16,
},
listItem: {
flexDirection: 'row',
},
listItemNumber: {
fontWeight: 'bold',
fontSize: 15,
lineHeight: 20,
color: 'panelForegroundSecondaryLabel',
},
listItemContent: {
flexShrink: 1,
fontSize: 15,
lineHeight: 20,
color: 'panelForegroundSecondaryLabel',
},
};
export default ConnectEthereum;
diff --git a/native/account/terms-and-privacy-modal.react.js b/native/account/terms-and-privacy-modal.react.js
index f7e2adfc3..fa970c243 100644
--- a/native/account/terms-and-privacy-modal.react.js
+++ b/native/account/terms-and-privacy-modal.react.js
@@ -1,190 +1,190 @@
// @flow
import { useIsFocused } from '@react-navigation/native';
import * as React from 'react';
import {
ActivityIndicator,
BackHandler,
Linking,
Platform,
Text,
View,
} from 'react-native';
import {
policyAcknowledgment,
policyAcknowledgmentActionTypes,
} from 'lib/actions/user-actions.js';
import { type PolicyType, policyTypes } from 'lib/facts/policies.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import { acknowledgePolicy } from 'lib/utils/policy-acknowledge-utlis.js';
import Button from '../components/button.react.js';
import Modal from '../components/modal.react.js';
import type { RootNavigationProp } from '../navigation/root-navigator.react.js';
import type { NavigationRoute } from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { useStyles } from '../themes/colors.js';
export type TermsAndPrivacyModalParams = {
+policyType: PolicyType,
};
type Props = {
+navigation: RootNavigationProp<'TermsAndPrivacyModal'>,
+route: NavigationRoute<'TermsAndPrivacyModal'>,
};
const loadingStatusSelector = createLoadingStatusSelector(
policyAcknowledgmentActionTypes,
);
function TermsAndPrivacyModal(props: Props): React.Node {
const loadingStatus = useSelector(loadingStatusSelector);
const [acknowledgmentError, setAcknowledgmentError] = React.useState('');
const sendAcknowledgmentRequest = useServerCall(policyAcknowledgment);
const dispatchActionPromise = useDispatchActionPromise();
const policyType = props.route.params.policyType;
const policyState = useSelector(store => store.userPolicies[policyType]);
const isAcknowledged = policyState?.isAcknowledged;
const isFocused = useIsFocused();
React.useEffect(() => {
if (isAcknowledged && isFocused) {
props.navigation.goBack();
}
}, [isAcknowledged, props.navigation, isFocused]);
const onAccept = React.useCallback(() => {
acknowledgePolicy(
policyTypes.tosAndPrivacyPolicy,
dispatchActionPromise,
sendAcknowledgmentRequest,
setAcknowledgmentError,
);
}, [dispatchActionPromise, sendAcknowledgmentRequest]);
const styles = useStyles(unboundStyles);
const buttonContent = React.useMemo(() => {
if (loadingStatus === 'loading') {
return (
<View style={styles.loading}>
<ActivityIndicator size="small" color="#D3D3D3" />
</View>
);
}
return <Text style={styles.buttonText}>I accept</Text>;
}, [loadingStatus, styles.buttonText, styles.loading]);
const onBackPress = props.navigation.isFocused;
React.useEffect(() => {
BackHandler.addEventListener('hardwareBackPress', onBackPress);
return () => {
BackHandler.removeEventListener('hardwareBackPress', onBackPress);
};
}, [onBackPress]);
const safeAreaEdges = ['top', 'bottom'];
return (
<Modal
disableClosing={true}
modalStyle={styles.modal}
safeAreaEdges={safeAreaEdges}
>
<Text style={styles.header}>Terms of Service and Privacy Policy</Text>
<Text style={styles.label}>
<Text>We recently updated our&nbsp;</Text>
<Text style={styles.link} onPress={onTermsOfUsePressed}>
Terms of Service
</Text>
<Text>&nbsp;&amp;&nbsp;</Text>
<Text style={styles.link} onPress={onPrivacyPolicyPressed}>
Privacy Policy
</Text>
<Text>
- . In order to continue using Comm, we&apos;re asking you to read
+ . In order to continue using Comm, we&rsquo;re asking you to read
through, acknowledge, and accept the updated policies.
</Text>
</Text>
<View style={styles.buttonsContainer}>
<Button style={styles.button} onPress={onAccept}>
<Text style={styles.buttonText}>{buttonContent}</Text>
</Button>
<Text style={styles.error}>{acknowledgmentError}</Text>
</View>
</Modal>
);
}
const unboundStyles = {
modal: {
backgroundColor: 'modalForeground',
paddingBottom: 10,
paddingTop: 32,
paddingHorizontal: 32,
flex: 0,
borderColor: 'modalForegroundBorder',
},
header: {
color: 'modalForegroundLabel',
fontSize: 20,
fontWeight: '600',
textAlign: 'center',
paddingBottom: 16,
},
label: {
color: 'modalForegroundSecondaryLabel',
fontSize: 16,
lineHeight: 20,
textAlign: 'center',
},
link: {
color: 'purpleLink',
fontWeight: 'bold',
},
buttonsContainer: {
flexDirection: 'column',
marginTop: 24,
height: 72,
paddingHorizontal: 16,
},
button: {
borderRadius: 5,
height: 48,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'purpleButton',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
textAlign: 'center',
},
error: {
marginTop: 6,
fontStyle: 'italic',
color: 'redText',
textAlign: 'center',
},
loading: {
paddingTop: Platform.OS === 'android' ? 0 : 6,
},
};
const onTermsOfUsePressed = () => {
Linking.openURL('https://comm.app/terms');
};
const onPrivacyPolicyPressed = () => {
Linking.openURL('https://comm.app/privacy');
};
export default TermsAndPrivacyModal;
diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js
index d1424369b..84aef3813 100644
--- a/native/chat/chat-input-bar.react.js
+++ b/native/chat/chat-input-bar.react.js
@@ -1,1309 +1,1309 @@
// @flow
import Icon from '@expo/vector-icons/Ionicons.js';
import invariant from 'invariant';
import _throttle from 'lodash/throttle.js';
import * as React from 'react';
import {
View,
TextInput,
TouchableOpacity,
Platform,
Text,
ActivityIndicator,
TouchableWithoutFeedback,
NativeAppEventEmitter,
} from 'react-native';
import Alert from 'react-native/Libraries/Alert/Alert.js';
import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input';
import Animated, {
EasingNode,
FadeInDown,
FadeOutDown,
} from 'react-native-reanimated';
import { useDispatch } from 'react-redux';
import {
moveDraftActionType,
updateDraftActionType,
} from 'lib/actions/draft-actions.js';
import {
joinThreadActionTypes,
joinThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js';
import { colorIsDark } from 'lib/shared/color-utils.js';
import { useEditMessage } from 'lib/shared/edit-messages-utils.js';
import {
getTypeaheadUserSuggestions,
getTypeaheadRegexMatches,
type Selection,
getMentionsCandidates,
} from 'lib/shared/mention-utils.js';
import {
localIDPrefix,
trimMessage,
useMessagePreview,
messageKey,
type MessagePreviewResult,
} from 'lib/shared/message-utils.js';
import SearchIndex from 'lib/shared/search-index.js';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
checkIfDefaultMembersAreVoiced,
draftKeyFromThreadID,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import type { PhotoPaste } from 'lib/types/media-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import type {
SendEditMessageResponse,
MessageInfo,
} from 'lib/types/message-types.js';
import type { Dispatch } from 'lib/types/redux-types.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type ThreadInfo,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
type RelativeMemberInfo,
} from 'lib/types/thread-types.js';
import { type UserInfos } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { ChatContext } from './chat-context.js';
import type { ChatNavigationProp } from './chat.react.js';
import TypeaheadTooltip from './typeahead-tooltip.react.js';
import Button from '../components/button.react.js';
// eslint-disable-next-line import/extensions
import ClearableTextInput from '../components/clearable-text-input.react';
import type { SyncedSelectionData } from '../components/selectable-text-input.js';
// eslint-disable-next-line import/extensions
import SelectableTextInput from '../components/selectable-text-input.react';
import { SingleLine } from '../components/single-line.react.js';
import SWMansionIcon from '../components/swmansion-icon.react.js';
import {
type InputState,
InputStateContext,
type EditInputBarMessageParameters,
} from '../input/input-state.js';
import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js';
import {
type KeyboardState,
KeyboardContext,
} from '../keyboard/keyboard-state.js';
import { getKeyboardHeight } from '../keyboard/keyboard.js';
import { getDefaultTextMessageRules } from '../markdown/rules.react.js';
import {
nonThreadCalendarQuery,
activeThreadSelector,
} from '../navigation/nav-selectors.js';
import { NavContext } from '../navigation/navigation-context.js';
import {
type NavigationRoute,
ChatCameraModalRouteName,
ImagePasteModalRouteName,
} from '../navigation/route-names.js';
import { useSelector } from '../redux/redux-utils.js';
import { type Colors, useStyles, useColors } from '../themes/colors.js';
import type { LayoutEvent } from '../types/react-native.js';
import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js';
import { runTiming } from '../utils/animation-utils.js';
import { exitEditAlert } from '../utils/edit-messages-utils.js';
import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js';
/* eslint-disable import/no-named-as-default-member */
const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } =
Animated;
/* eslint-enable import/no-named-as-default-member */
const expandoButtonsAnimationConfig = {
duration: 150,
easing: EasingNode.inOut(EasingNode.ease),
};
const sendButtonAnimationConfig = {
duration: 150,
easing: EasingNode.inOut(EasingNode.ease),
};
type BaseProps = {
+threadInfo: ThreadInfo,
};
type Props = {
...BaseProps,
+viewerID: ?string,
+draft: string,
+joinThreadLoadingStatus: LoadingStatus,
+threadCreationInProgress: boolean,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+userInfos: UserInfos,
+colors: Colors,
+styles: typeof unboundStyles,
+onInputBarLayout?: (event: LayoutEvent) => mixed,
+openCamera: () => mixed,
+isActive: boolean,
+keyboardState: ?KeyboardState,
+dispatch: Dispatch,
+dispatchActionPromise: DispatchActionPromise,
+joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>,
+inputState: ?InputState,
+userSearchIndex: SearchIndex,
+mentionsCandidates: $ReadOnlyArray<RelativeMemberInfo>,
+parentThreadInfo: ?ThreadInfo,
+editedMessagePreview: ?MessagePreviewResult,
+editedMessageInfo: ?MessageInfo,
+editMessage: (
messageID: string,
text: string,
) => Promise<SendEditMessageResponse>,
+navigation: ?ChatNavigationProp<'MessageList'>,
};
type State = {
+text: string,
+textEdited: boolean,
+buttonsExpanded: boolean,
+selectionState: SyncedSelectionData,
+isExitingEditMode: boolean,
};
class ChatInputBar extends React.PureComponent<Props, State> {
textInput: ?React.ElementRef<typeof TextInput>;
clearableTextInput: ?ClearableTextInput;
selectableTextInput: ?React.ElementRef<typeof SelectableTextInput>;
expandoButtonsOpen: Value;
targetExpandoButtonsOpen: Value;
expandoButtonsStyle: AnimatedViewStyle;
cameraRollIconStyle: AnimatedViewStyle;
cameraIconStyle: AnimatedViewStyle;
expandIconStyle: AnimatedViewStyle;
sendButtonContainerOpen: Value;
targetSendButtonContainerOpen: Value;
sendButtonContainerStyle: AnimatedViewStyle;
clearBeforeRemoveListener: () => void;
constructor(props: Props) {
super(props);
this.state = {
text: props.draft,
textEdited: false,
buttonsExpanded: true,
selectionState: { text: props.draft, selection: { start: 0, end: 0 } },
isExitingEditMode: false,
};
this.setUpActionIconAnimations();
this.setUpSendIconAnimations();
}
setUpActionIconAnimations() {
this.expandoButtonsOpen = new Value(1);
this.targetExpandoButtonsOpen = new Value(1);
const prevTargetExpandoButtonsOpen = new Value(1);
const expandoButtonClock = new Clock();
const expandoButtonsOpen = block([
cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [
stopClock(expandoButtonClock),
set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen),
]),
cond(
neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen),
set(
this.expandoButtonsOpen,
runTiming(
expandoButtonClock,
this.expandoButtonsOpen,
this.targetExpandoButtonsOpen,
true,
expandoButtonsAnimationConfig,
),
),
),
this.expandoButtonsOpen,
]);
this.cameraRollIconStyle = {
...unboundStyles.cameraRollIcon,
opacity: expandoButtonsOpen,
};
this.cameraIconStyle = {
...unboundStyles.cameraIcon,
opacity: expandoButtonsOpen,
};
const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, {
inputRange: [0, 1],
outputRange: [26, 66],
});
this.expandoButtonsStyle = {
...unboundStyles.expandoButtons,
width: expandoButtonsWidth,
};
const expandOpacity = sub(1, expandoButtonsOpen);
this.expandIconStyle = {
...unboundStyles.expandIcon,
opacity: expandOpacity,
};
}
setUpSendIconAnimations() {
const initialSendButtonContainerOpen = trimMessage(this.props.draft)
? 1
: 0;
this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen);
this.targetSendButtonContainerOpen = new Value(
initialSendButtonContainerOpen,
);
const prevTargetSendButtonContainerOpen = new Value(
initialSendButtonContainerOpen,
);
const sendButtonClock = new Clock();
const sendButtonContainerOpen = block([
cond(
neq(
this.targetSendButtonContainerOpen,
prevTargetSendButtonContainerOpen,
),
[
stopClock(sendButtonClock),
set(
prevTargetSendButtonContainerOpen,
this.targetSendButtonContainerOpen,
),
],
),
cond(
neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen),
set(
this.sendButtonContainerOpen,
runTiming(
sendButtonClock,
this.sendButtonContainerOpen,
this.targetSendButtonContainerOpen,
true,
sendButtonAnimationConfig,
),
),
),
this.sendButtonContainerOpen,
]);
const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, {
inputRange: [0, 1],
outputRange: [4, 38],
});
this.sendButtonContainerStyle = { width: sendButtonContainerWidth };
}
static mediaGalleryOpen(props: Props) {
const { keyboardState } = props;
return !!(keyboardState && keyboardState.mediaGalleryOpen);
}
static systemKeyboardShowing(props: Props) {
const { keyboardState } = props;
return !!(keyboardState && keyboardState.systemKeyboardShowing);
}
get systemKeyboardShowing() {
return ChatInputBar.systemKeyboardShowing(this.props);
}
immediatelyShowSendButton() {
this.sendButtonContainerOpen.setValue(1);
this.targetSendButtonContainerOpen.setValue(1);
}
updateSendButton(currentText: string) {
if (this.shouldShowTextInput) {
this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1);
} else {
this.setUpSendIconAnimations();
}
}
componentDidMount() {
if (this.props.isActive) {
this.addEditInputMessageListener();
}
if (this.props.navigation) {
this.clearBeforeRemoveListener = this.props.navigation.addListener(
'beforeRemove',
this.onNavigationBeforeRemove,
);
}
}
componentWillUnmount() {
if (this.props.isActive) {
this.removeEditInputMessageListener();
}
if (this.clearBeforeRemoveListener) {
this.clearBeforeRemoveListener();
}
}
componentDidUpdate(prevProps: Props, prevState: State) {
if (
this.state.textEdited &&
this.state.text &&
this.props.threadInfo.id !== prevProps.threadInfo.id
) {
this.props.dispatch({
type: moveDraftActionType,
payload: {
oldKey: draftKeyFromThreadID(prevProps.threadInfo.id),
newKey: draftKeyFromThreadID(this.props.threadInfo.id),
},
});
} else if (!this.state.textEdited && this.props.draft !== prevProps.draft) {
this.setState({ text: this.props.draft });
}
if (this.props.isActive && !prevProps.isActive) {
this.addEditInputMessageListener();
} else if (!this.props.isActive && prevProps.isActive) {
this.removeEditInputMessageListener();
}
const currentText = trimMessage(this.state.text);
const prevText = trimMessage(prevState.text);
if (
(currentText === '' && prevText !== '') ||
(currentText !== '' && prevText === '')
) {
this.updateSendButton(currentText);
}
const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing(
this.props,
);
const systemKeyboardWasShowing =
ChatInputBar.systemKeyboardShowing(prevProps);
if (systemKeyboardIsShowing && !systemKeyboardWasShowing) {
this.hideButtons();
} else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) {
this.expandButtons();
}
const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props);
const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps);
if (!imageGalleryIsOpen && imageGalleryWasOpen) {
this.hideButtons();
} else if (imageGalleryIsOpen && !imageGalleryWasOpen) {
this.expandButtons();
this.setIOSKeyboardHeight();
}
}
addEditInputMessageListener() {
invariant(
this.props.inputState,
'inputState should be set in addEditInputMessageListener',
);
this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText);
}
removeEditInputMessageListener() {
invariant(
this.props.inputState,
'inputState should be set in removeEditInputMessageListener',
);
this.props.inputState.removeEditInputMessageListener(
this.focusAndUpdateText,
);
}
setIOSKeyboardHeight() {
if (Platform.OS !== 'ios') {
return;
}
const { textInput } = this;
if (!textInput) {
return;
}
const keyboardHeight = getKeyboardHeight();
if (keyboardHeight === null || keyboardHeight === undefined) {
return;
}
TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight);
}
get shouldShowTextInput(): boolean {
if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) {
return true;
}
// If the thread is created by somebody else while the viewer is attempting
// to create it, the threadInfo might be modified in-place
// and won't list the viewer as a member,
// which will end up hiding the input.
// In this case, we will assume that our creation action
// will get translated into a join, and as long
// as members are voiced, we can show the input.
if (!this.props.threadCreationInProgress) {
return false;
}
return checkIfDefaultMembersAreVoiced(this.props.threadInfo);
}
render() {
const isMember = viewerIsMember(this.props.threadInfo);
const canJoin = threadHasPermission(
this.props.threadInfo,
threadPermissions.JOIN_THREAD,
);
let joinButton = null;
const threadColor = `#${this.props.threadInfo.color}`;
const isEditMode = this.isEditMode();
if (!isMember && canJoin && !this.props.threadCreationInProgress) {
let buttonContent;
if (this.props.joinThreadLoadingStatus === 'loading') {
buttonContent = (
<ActivityIndicator
size="small"
color="white"
style={this.props.styles.joinThreadLoadingIndicator}
/>
);
} else {
const textStyle = colorIsDark(this.props.threadInfo.color)
? this.props.styles.joinButtonTextLight
: this.props.styles.joinButtonTextDark;
buttonContent = (
<View style={this.props.styles.joinButtonContent}>
<SWMansionIcon name="plus" style={textStyle} />
<Text style={textStyle}>Join Chat</Text>
</View>
);
}
joinButton = (
<View style={this.props.styles.joinButtonContainer}>
<Button
onPress={this.onPressJoin}
iosActiveOpacity={0.85}
style={[
this.props.styles.joinButton,
{ backgroundColor: threadColor },
]}
>
{buttonContent}
</Button>
</View>
);
}
const typeaheadRegexMatches = getTypeaheadRegexMatches(
this.state.selectionState.text,
this.state.selectionState.selection,
nativeTypeaheadRegex,
);
let typeaheadTooltip = null;
if (typeaheadRegexMatches && !isEditMode) {
const typeaheadMatchedStrings = {
textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '',
usernamePrefix: typeaheadRegexMatches[4] ?? '',
};
const suggestedUsers = getTypeaheadUserSuggestions(
this.props.userSearchIndex,
this.props.mentionsCandidates,
this.props.viewerID,
typeaheadMatchedStrings.usernamePrefix,
);
if (suggestedUsers.length > 0) {
typeaheadTooltip = (
<TypeaheadTooltip
text={this.state.text}
matchedStrings={typeaheadMatchedStrings}
suggestedUsers={suggestedUsers}
focusAndUpdateTextAndSelection={this.focusAndUpdateTextAndSelection}
/>
);
}
}
let content;
const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced(
this.props.threadInfo,
);
if (this.shouldShowTextInput) {
content = this.renderInput();
} else if (
threadFrozenDueToViewerBlock(
this.props.threadInfo,
this.props.viewerID,
this.props.userInfos,
) &&
threadActualMembers(this.props.threadInfo.members).length === 2
) {
content = (
<Text style={this.props.styles.explanation}>
- You can&apos;t send messages to a user that you&apos;ve blocked.
+ You can&rsquo;t send messages to a user that you&rsquo;ve blocked.
</Text>
);
} else if (isMember) {
content = (
<Text style={this.props.styles.explanation}>
- You don&apos;t have permission to send messages.
+ You don&rsquo;t have permission to send messages.
</Text>
);
} else if (defaultMembersAreVoiced && canJoin) {
content = null;
} else {
content = (
<Text style={this.props.styles.explanation}>
- You don&apos;t have permission to send messages.
+ You don&rsquo;t have permission to send messages.
</Text>
);
}
const keyboardInputHost =
Platform.OS === 'android' ? null : (
<KeyboardInputHost textInputRef={this.textInput} />
);
let editedMessage;
if (isEditMode && this.props.editedMessagePreview) {
const { message } = this.props.editedMessagePreview;
editedMessage = (
<AnimatedView
style={this.props.styles.editView}
entering={FadeInDown}
exiting={FadeOutDown}
>
<View style={this.props.styles.editViewContent}>
<TouchableOpacity
onPress={this.scrollToEditedMessage}
activeOpacity={0.4}
>
<Text
style={[{ color: threadColor }, this.props.styles.editingLabel]}
>
Editing message
</Text>
<SingleLine style={this.props.styles.editingMessagePreview}>
{message.text}
</SingleLine>
</TouchableOpacity>
</View>
<SWMansionIcon
style={this.props.styles.exitEditButton}
name="cross"
size={22}
color={threadColor}
onPress={this.onPressExitEditMode}
/>
</AnimatedView>
);
}
return (
<AnimatedView
style={this.props.styles.container}
onLayout={this.props.onInputBarLayout}
>
{typeaheadTooltip}
{joinButton}
{editedMessage}
{content}
{keyboardInputHost}
</AnimatedView>
);
}
renderInput() {
const expandoButton = (
<TouchableOpacity
onPress={this.expandButtons}
activeOpacity={0.4}
style={this.props.styles.expandButton}
>
<AnimatedView style={this.expandIconStyle}>
<SWMansionIcon
name="chevron-right"
size={22}
color={`#${this.props.threadInfo.color}`}
/>
</AnimatedView>
</TouchableOpacity>
);
const threadColor = `#${this.props.threadInfo.color}`;
const expandoButtonsViewStyle = [this.props.styles.innerExpandoButtons];
if (this.isEditMode()) {
expandoButtonsViewStyle.push({ display: 'none' });
}
return (
<TouchableWithoutFeedback onPress={this.dismissKeyboard}>
<View style={this.props.styles.inputContainer}>
<AnimatedView style={this.expandoButtonsStyle}>
<View style={expandoButtonsViewStyle}>
{this.state.buttonsExpanded ? expandoButton : null}
<TouchableOpacity
onPress={this.showMediaGallery}
activeOpacity={0.4}
>
<AnimatedView style={this.cameraRollIconStyle}>
<SWMansionIcon
name="image-1"
size={28}
color={`#${this.props.threadInfo.color}`}
/>
</AnimatedView>
</TouchableOpacity>
<TouchableOpacity
onPress={this.props.openCamera}
activeOpacity={0.4}
disabled={!this.state.buttonsExpanded}
>
<AnimatedView style={this.cameraIconStyle}>
<SWMansionIcon
name="camera"
size={28}
color={`#${this.props.threadInfo.color}`}
/>
</AnimatedView>
</TouchableOpacity>
{this.state.buttonsExpanded ? null : expandoButton}
</View>
</AnimatedView>
<SelectableTextInput
allowImagePasteForThreadID={this.props.threadInfo.id}
value={this.state.text}
onChangeText={this.updateText}
selection={this.state.selectionState.selection}
onUpdateSyncedSelectionData={this.updateSelectionState}
placeholder="Send a message..."
placeholderTextColor={this.props.colors.listInputButton}
multiline={true}
style={this.props.styles.textInput}
textInputRef={this.textInputRef}
clearableTextInputRef={this.clearableTextInputRef}
ref={this.selectableTextInputRef}
selectionColor={`#${this.props.threadInfo.color}`}
/>
<AnimatedView style={this.sendButtonContainerStyle}>
<TouchableOpacity
onPress={this.onSend}
activeOpacity={0.4}
style={this.props.styles.sendButton}
disabled={trimMessage(this.state.text) === ''}
>
<Icon
name="md-send"
size={25}
style={this.props.styles.sendIcon}
color={threadColor}
/>
</TouchableOpacity>
</AnimatedView>
</View>
</TouchableWithoutFeedback>
);
}
textInputRef = (textInput: ?React.ElementRef<typeof TextInput>) => {
this.textInput = textInput;
};
clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => {
this.clearableTextInput = clearableTextInput;
};
selectableTextInputRef = (
selectableTextInput: ?React.ElementRef<typeof SelectableTextInput>,
) => {
this.selectableTextInput = selectableTextInput;
};
updateText = (text: string) => {
this.setState({ text, textEdited: true });
if (this.isEditMode() || this.state.isExitingEditMode) {
return;
}
this.saveDraft(text);
};
updateSelectionState: (data: SyncedSelectionData) => void = data => {
this.setState({ selectionState: data });
};
saveDraft = _throttle(text => {
this.props.dispatch({
type: updateDraftActionType,
payload: {
key: draftKeyFromThreadID(this.props.threadInfo.id),
text,
},
});
}, 400);
focusAndUpdateTextAndSelection = (text: string, selection: Selection) => {
this.selectableTextInput?.prepareForSelectionMutation(text, selection);
this.setState({
text,
textEdited: true,
selectionState: { text, selection },
});
this.saveDraft(text);
this.focusAndUpdateButtonsVisibility();
};
focusAndUpdateText = (params: EditInputBarMessageParameters) => {
const { message: text, mode } = params;
const currentText = this.state.text;
if (mode === 'replace') {
this.updateText(text);
} else if (!currentText.startsWith(text)) {
const prependedText = text.concat(currentText);
this.updateText(prependedText);
}
this.focusAndUpdateButtonsVisibility();
};
focusAndUpdateButtonsVisibility = () => {
const { textInput } = this;
if (!textInput) {
return;
}
this.immediatelyShowSendButton();
this.immediatelyHideButtons();
textInput.focus();
};
onSend = async () => {
if (!trimMessage(this.state.text)) {
return;
}
const editedMessage = this.getEditedMessage();
if (editedMessage && editedMessage.id) {
this.editMessage(editedMessage.id, this.state.text);
return;
}
this.updateSendButton('');
const { clearableTextInput } = this;
invariant(
clearableTextInput,
'clearableTextInput should be sent in onSend',
);
let text = await clearableTextInput.getValueAndReset();
text = trimMessage(text);
if (!text) {
return;
}
const localID = `${localIDPrefix}${this.props.nextLocalID}`;
const creatorID = this.props.viewerID;
invariant(creatorID, 'should have viewer ID in order to send a message');
invariant(
this.props.inputState,
'inputState should be set in ChatInputBar.onSend',
);
this.props.inputState.sendTextMessage(
{
type: messageTypes.TEXT,
localID,
threadID: this.props.threadInfo.id,
text,
creatorID,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
};
isEditMode = () => {
const editState = this.props.inputState?.editState;
const isThisThread =
editState?.editedMessage?.threadID === this.props.threadInfo.id;
return editState && editState.editedMessage !== null && isThisThread;
};
isMessageEdited = () => {
let text = this.state.text;
text = trimMessage(text);
const originalText = this.props.editedMessageInfo?.text;
return text !== originalText;
};
editMessage = async (messageID: string, text: string) => {
if (!this.isMessageEdited()) {
this.exitEditMode();
return;
}
text = trimMessage(text);
try {
await this.props.editMessage(messageID, text);
this.exitEditMode();
} catch (error) {
Alert.alert(
'Couldn’t edit the message',
'Please try again later',
[{ text: 'OK' }],
{
cancelable: true,
},
);
}
};
getEditedMessage = () => {
const editState = this.props.inputState?.editState;
return editState?.editedMessage;
};
onPressExitEditMode = () => {
if (!this.isMessageEdited()) {
this.exitEditMode();
return;
}
exitEditAlert(this.exitEditMode);
};
scrollToEditedMessage = () => {
const editedMessage = this.getEditedMessage();
if (!editedMessage) {
return;
}
const editedMessageKey = messageKey(editedMessage);
this.props.inputState?.scrollToMessage(editedMessageKey);
};
exitEditMode = () => {
this.props.inputState?.setEditedMessage(null, () => {
this.updateText(this.props.draft);
this.focusAndUpdateButtonsVisibility();
this.updateSendButton(this.props.draft);
});
};
onNavigationBeforeRemove = e => {
if (!this.isEditMode()) {
return;
}
const { action } = e.data;
e.preventDefault();
const saveExit = () => {
this.props.inputState?.setEditedMessage(null, () => {
this.setState({ isExitingEditMode: true }, () => {
if (!this.props.navigation) {
return;
}
this.props.navigation.dispatch(action);
});
});
};
if (!this.isMessageEdited()) {
saveExit();
return;
}
exitEditAlert(saveExit);
};
onPressJoin = () => {
this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction());
};
async joinAction() {
const query = this.props.calendarQuery();
return await this.props.joinThread({
threadID: this.props.threadInfo.id,
calendarQuery: {
startDate: query.startDate,
endDate: query.endDate,
filters: [
...query.filters,
{ type: 'threads', threadIDs: [this.props.threadInfo.id] },
],
},
});
}
expandButtons = () => {
if (this.state.buttonsExpanded || this.isEditMode()) {
return;
}
this.targetExpandoButtonsOpen.setValue(1);
this.setState({ buttonsExpanded: true });
};
hideButtons() {
if (
ChatInputBar.mediaGalleryOpen(this.props) ||
!this.systemKeyboardShowing ||
!this.state.buttonsExpanded
) {
return;
}
this.targetExpandoButtonsOpen.setValue(0);
this.setState({ buttonsExpanded: false });
}
immediatelyHideButtons() {
this.expandoButtonsOpen.setValue(0);
this.targetExpandoButtonsOpen.setValue(0);
this.setState({ buttonsExpanded: false });
}
showMediaGallery = () => {
const { keyboardState } = this.props;
invariant(keyboardState, 'keyboardState should be initialized');
keyboardState.showMediaGallery(this.props.threadInfo);
};
dismissKeyboard = () => {
const { keyboardState } = this.props;
keyboardState && keyboardState.dismissKeyboard();
};
}
const unboundStyles = {
cameraIcon: {
paddingBottom: Platform.OS === 'android' ? 11 : 8,
paddingRight: 5,
},
cameraRollIcon: {
paddingBottom: Platform.OS === 'android' ? 11 : 8,
paddingRight: 5,
},
container: {
backgroundColor: 'listBackground',
paddingLeft: Platform.OS === 'android' ? 10 : 5,
},
expandButton: {
bottom: 0,
position: 'absolute',
right: 0,
},
expandIcon: {
paddingBottom: Platform.OS === 'android' ? 13 : 11,
paddingRight: 2,
},
expandoButtons: {
alignSelf: 'flex-end',
},
explanation: {
color: 'listBackgroundSecondaryLabel',
paddingBottom: 4,
paddingTop: 1,
textAlign: 'center',
},
innerExpandoButtons: {
alignItems: 'flex-end',
alignSelf: 'flex-end',
flexDirection: 'row',
},
inputContainer: {
flexDirection: 'row',
},
joinButton: {
borderRadius: 8,
flex: 1,
justifyContent: 'center',
marginHorizontal: 12,
marginVertical: 3,
},
joinButtonContainer: {
flexDirection: 'row',
height: 48,
marginBottom: 8,
},
editView: {
marginLeft: 20,
marginRight: 20,
padding: 10,
flexDirection: 'row',
justifyContent: 'space-between',
},
editViewContent: {
flex: 1,
paddingRight: 6,
},
exitEditButton: {
marginTop: 6,
},
editingLabel: {
paddingBottom: 4,
},
editingMessagePreview: {
color: 'listForegroundLabel',
},
joinButtonContent: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
joinButtonTextLight: {
color: 'white',
fontSize: 20,
marginHorizontal: 4,
},
joinButtonTextDark: {
color: 'black',
fontSize: 20,
marginHorizontal: 4,
},
joinThreadLoadingIndicator: {
paddingVertical: 2,
},
sendButton: {
position: 'absolute',
bottom: 4,
left: 0,
},
sendIcon: {
paddingLeft: 9,
paddingRight: 8,
paddingVertical: 6,
},
textInput: {
backgroundColor: 'listInputBackground',
borderRadius: 12,
color: 'listForegroundLabel',
fontSize: 16,
marginLeft: 4,
marginRight: 4,
marginTop: 6,
marginBottom: 8,
maxHeight: 110,
paddingHorizontal: 10,
paddingVertical: 5,
},
};
const joinThreadLoadingStatusSelector = createLoadingStatusSelector(
joinThreadActionTypes,
);
const createThreadLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
type ConnectedChatInputBarBaseProps = {
...BaseProps,
+onInputBarLayout?: (event: LayoutEvent) => mixed,
+openCamera: () => mixed,
+navigation?: ChatNavigationProp<'MessageList'>,
};
function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) {
const navContext = React.useContext(NavContext);
const keyboardState = React.useContext(KeyboardContext);
const inputState = React.useContext(InputStateContext);
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const draft = useSelector(
state =>
state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '',
);
const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector);
const createThreadLoadingStatus = useSelector(
createThreadLoadingStatusSelector,
);
const threadCreationInProgress = createThreadLoadingStatus === 'loading';
const calendarQuery = useSelector(state =>
nonThreadCalendarQuery({
redux: state,
navContext,
}),
);
const nextLocalID = useSelector(state => state.nextLocalID);
const userInfos = useSelector(state => state.userStore.userInfos);
const styles = useStyles(unboundStyles);
const colors = useColors();
const isActive = React.useMemo(
() => props.threadInfo.id === activeThreadSelector(navContext),
[props.threadInfo.id, navContext],
);
const dispatch = useDispatch();
const dispatchActionPromise = useDispatchActionPromise();
const callJoinThread = useServerCall(joinThread);
const userSearchIndex = useSelector(userStoreSearchIndex);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const mentionsCandidates = getMentionsCandidates(
props.threadInfo,
parentThreadInfo,
);
const editedMessageInfo = inputState?.editState.editedMessage;
const editedMessagePreview = useMessagePreview(
editedMessageInfo,
props.threadInfo,
getDefaultTextMessageRules().simpleMarkdownRules,
);
const editMessage = useEditMessage();
return (
<ChatInputBar
{...props}
viewerID={viewerID}
draft={draft}
joinThreadLoadingStatus={joinThreadLoadingStatus}
threadCreationInProgress={threadCreationInProgress}
calendarQuery={calendarQuery}
nextLocalID={nextLocalID}
userInfos={userInfos}
colors={colors}
styles={styles}
isActive={isActive}
keyboardState={keyboardState}
dispatch={dispatch}
dispatchActionPromise={dispatchActionPromise}
joinThread={callJoinThread}
inputState={inputState}
userSearchIndex={userSearchIndex}
mentionsCandidates={mentionsCandidates}
parentThreadInfo={parentThreadInfo}
editedMessagePreview={editedMessagePreview}
editedMessageInfo={editedMessageInfo}
editMessage={editMessage}
navigation={props.navigation}
/>
);
}
type DummyChatInputBarProps = {
...BaseProps,
+onHeightMeasured: (height: number) => mixed,
};
const noop = () => {};
function DummyChatInputBar(props: DummyChatInputBarProps): React.Node {
const { onHeightMeasured, ...restProps } = props;
const onInputBarLayout = React.useCallback(
(event: LayoutEvent) => {
const { height } = event.nativeEvent.layout;
onHeightMeasured(height);
},
[onHeightMeasured],
);
return (
<View pointerEvents="none">
<ConnectedChatInputBarBase
{...restProps}
onInputBarLayout={onInputBarLayout}
openCamera={noop}
/>
</View>
);
}
type ChatInputBarProps = {
...BaseProps,
+navigation: ChatNavigationProp<'MessageList'>,
+route: NavigationRoute<'MessageList'>,
};
const ConnectedChatInputBar: React.ComponentType<ChatInputBarProps> =
React.memo<ChatInputBarProps>(function ConnectedChatInputBar(
props: ChatInputBarProps,
) {
const { navigation, route, ...restProps } = props;
const keyboardState = React.useContext(KeyboardContext);
const { threadInfo } = props;
const imagePastedCallback = React.useCallback(
imagePastedEvent => {
if (threadInfo.id !== imagePastedEvent.threadID) {
return;
}
const pastedImage: PhotoPaste = {
step: 'photo_paste',
dimensions: {
height: imagePastedEvent.height,
width: imagePastedEvent.width,
},
filename: imagePastedEvent.fileName,
uri: 'file://' + imagePastedEvent.filePath,
selectTime: 0,
sendTime: 0,
retries: 0,
};
navigation.navigate<'ImagePasteModal'>({
name: ImagePasteModalRouteName,
params: {
imagePasteStagingInfo: pastedImage,
thread: threadInfo,
},
});
},
[navigation, threadInfo],
);
React.useEffect(() => {
const imagePasteListener = NativeAppEventEmitter.addListener(
'imagePasted',
imagePastedCallback,
);
return () => imagePasteListener.remove();
}, [imagePastedCallback]);
const chatContext = React.useContext(ChatContext);
invariant(chatContext, 'should be set');
const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext;
const onInputBarLayout = React.useCallback(
(event: LayoutEvent) => {
const { height } = event.nativeEvent.layout;
setChatInputBarHeight(threadInfo.id, height);
},
[threadInfo.id, setChatInputBarHeight],
);
React.useEffect(() => {
return () => {
deleteChatInputBarHeight(threadInfo.id);
};
}, [deleteChatInputBarHeight, threadInfo.id]);
const openCamera = React.useCallback(() => {
keyboardState?.dismissKeyboard();
navigation.navigate<'ChatCameraModal'>({
name: ChatCameraModalRouteName,
params: {
presentedFrom: route.key,
thread: threadInfo,
},
});
}, [keyboardState, navigation, route.key, threadInfo]);
return (
<ConnectedChatInputBarBase
{...restProps}
onInputBarLayout={onInputBarLayout}
openCamera={openCamera}
navigation={navigation}
/>
);
});
export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar };
diff --git a/native/crash.react.js b/native/crash.react.js
index 15168c9b1..4d5527376 100644
--- a/native/crash.react.js
+++ b/native/crash.react.js
@@ -1,296 +1,296 @@
// @flow
import Icon from '@expo/vector-icons/FontAwesome.js';
import Clipboard from '@react-native-clipboard/clipboard';
import invariant from 'invariant';
import _shuffle from 'lodash/fp/shuffle.js';
import * as React from 'react';
import {
View,
Text,
Platform,
StyleSheet,
ScrollView,
ActivityIndicator,
} from 'react-native';
import {
sendReportActionTypes,
sendReport,
} from 'lib/actions/report-actions.js';
import { logOutActionTypes, logOut } from 'lib/actions/user-actions.js';
import { preRequestUserStateSelector } from 'lib/selectors/account-selectors.js';
import type { LogOutResult } from 'lib/types/account-types.js';
import {
type ErrorData,
type ClientReportCreationRequest,
type ReportCreationResponse,
reportTypes,
} from 'lib/types/report-types.js';
import type { PreRequestUserState } from 'lib/types/session-types.js';
import { actionLogger } from 'lib/utils/action-logger.js';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import { useIsReportEnabled } from 'lib/utils/report-utils.js';
import {
sanitizeReduxReport,
type ReduxCrashReport,
} from 'lib/utils/sanitization.js';
import sleep from 'lib/utils/sleep.js';
import Button from './components/button.react.js';
import ConnectedStatusBar from './connected-status-bar.react.js';
import { commCoreModule } from './native-modules.js';
import { persistConfig, codeVersion } from './redux/persist.js';
import { useSelector } from './redux/redux-utils.js';
import { wipeAndExit } from './utils/crash-utils.js';
const errorTitles = ['Oh no!!', 'Womp womp womp...'];
type BaseProps = {
+errorData: $ReadOnlyArray<ErrorData>,
};
type Props = {
...BaseProps,
// Redux state
+preRequestUserState: PreRequestUserState,
// Redux dispatch functions
+dispatchActionPromise: DispatchActionPromise,
// async functions that hit server APIs
+sendReport: (
request: ClientReportCreationRequest,
) => Promise<ReportCreationResponse>,
+logOut: (preRequestUserState: PreRequestUserState) => Promise<LogOutResult>,
+crashReportingEnabled: boolean,
};
type State = {
+errorReportID: ?string,
+doneWaiting: boolean,
};
class Crash extends React.PureComponent<Props, State> {
errorTitle = _shuffle(errorTitles)[0];
constructor(props) {
super(props);
this.state = {
errorReportID: null,
doneWaiting: !props.crashReportingEnabled,
};
}
componentDidMount() {
if (this.state.doneWaiting) {
return;
}
this.props.dispatchActionPromise(sendReportActionTypes, this.sendReport());
this.timeOut();
}
async timeOut() {
// If it takes more than 10s, give up and let the user exit
await sleep(10000);
this.setState({ doneWaiting: true });
}
render() {
const errorText = [...this.props.errorData]
.reverse()
.map(errorData => errorData.error.message)
.join('\n');
let crashID;
if (!this.state.doneWaiting) {
crashID = <ActivityIndicator size="small" color="black" />;
} else if (this.state.doneWaiting && this.state.errorReportID) {
crashID = (
<View style={styles.crashID}>
<Text style={styles.crashIDText}>Crash report ID:</Text>
<View style={styles.errorReportID}>
<Text style={styles.errorReportIDText}>
{this.state.errorReportID}
</Text>
<Button onPress={this.onCopyCrashReportID}>
<Text style={styles.copyCrashReportIDButtonText}>(Copy)</Text>
</Button>
</View>
</View>
);
} else {
crashID = (
<Text style={styles.text}>
Crash reporting can be enabled in the Profile tab.
</Text>
);
}
const buttonStyle = { opacity: Number(this.state.doneWaiting) };
return (
<View style={styles.container}>
<ConnectedStatusBar barStyle="dark-content" />
<Icon name="bug" size={32} color="red" />
<Text style={styles.header}>{this.errorTitle}</Text>
- <Text style={styles.text}>I&apos;m sorry, but the app crashed.</Text>
+ <Text style={styles.text}>I&rsquo;m sorry, but the app crashed.</Text>
{crashID}
<Text style={styles.text}>
- Here&apos;s some text that&apos;s probably not helpful:
+ Here&rsquo;s some text that&rsquo;s probably not helpful:
</Text>
<ScrollView style={styles.scrollView}>
<Text style={styles.errorText}>{errorText}</Text>
</ScrollView>
<View style={[styles.buttons, buttonStyle]}>
<Button onPress={this.onPressKill} style={styles.button}>
<Text style={styles.buttonText}>Kill the app</Text>
</Button>
<Button onPress={this.onPressWipe} style={styles.button}>
<Text style={styles.buttonText}>Wipe state and kill app</Text>
</Button>
</View>
</View>
);
}
async sendReport() {
const sanitizedReduxReport: ReduxCrashReport = sanitizeReduxReport({
preloadedState: actionLogger.preloadedState,
currentState: actionLogger.currentState,
actions: actionLogger.actions,
});
const result = await this.props.sendReport({
type: reportTypes.ERROR,
platformDetails: {
platform: Platform.OS,
codeVersion,
stateVersion: persistConfig.version,
},
errors: this.props.errorData.map(data => ({
errorMessage: data.error.message,
stack: data.error.stack,
componentStack: data.info && data.info.componentStack,
})),
...sanitizedReduxReport,
});
this.setState({
errorReportID: result.id,
doneWaiting: true,
});
}
onPressKill = () => {
if (!this.state.doneWaiting) {
return;
}
commCoreModule.terminate();
};
onPressWipe = async () => {
if (!this.state.doneWaiting) {
return;
}
this.props.dispatchActionPromise(logOutActionTypes, this.logOutAndExit());
};
async logOutAndExit() {
try {
await this.props.logOut(this.props.preRequestUserState);
} catch (e) {}
await wipeAndExit();
}
onCopyCrashReportID = () => {
invariant(this.state.errorReportID, 'should be set');
Clipboard.setString(this.state.errorReportID);
};
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#FF0000',
borderRadius: 5,
marginHorizontal: 10,
paddingHorizontal: 10,
paddingVertical: 5,
},
buttonText: {
color: 'white',
fontSize: 16,
},
buttons: {
flexDirection: 'row',
},
container: {
alignItems: 'center',
backgroundColor: 'white',
flex: 1,
justifyContent: 'center',
},
copyCrashReportIDButtonText: {
color: '#036AFF',
},
crashID: {
flexDirection: 'row',
paddingBottom: 12,
paddingTop: 2,
},
crashIDText: {
color: 'black',
paddingRight: 8,
},
errorReportID: {
flexDirection: 'row',
height: 20,
},
errorReportIDText: {
color: 'black',
paddingRight: 8,
},
errorText: {
color: 'black',
fontFamily: Platform.select({
ios: 'Menlo',
default: 'monospace',
}),
},
header: {
color: 'black',
fontSize: 24,
paddingBottom: 24,
},
scrollView: {
flex: 1,
marginBottom: 24,
marginTop: 12,
maxHeight: 200,
paddingHorizontal: 50,
},
text: {
color: 'black',
paddingBottom: 12,
},
});
const ConnectedCrash: React.ComponentType<BaseProps> = React.memo<BaseProps>(
function ConnectedCrash(props: BaseProps) {
const preRequestUserState = useSelector(preRequestUserStateSelector);
const dispatchActionPromise = useDispatchActionPromise();
const callSendReport = useServerCall(sendReport);
const callLogOut = useServerCall(logOut);
const crashReportingEnabled = useIsReportEnabled('crashReports');
return (
<Crash
{...props}
preRequestUserState={preRequestUserState}
dispatchActionPromise={dispatchActionPromise}
sendReport={callSendReport}
logOut={callLogOut}
crashReportingEnabled={crashReportingEnabled}
/>
);
},
);
export default ConnectedCrash;
diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js
index e22d834ce..4108a35fa 100644
--- a/web/chat/chat-input-bar.react.js
+++ b/web/chat/chat-input-bar.react.js
@@ -1,657 +1,657 @@
// @flow
import invariant from 'invariant';
import _difference from 'lodash/fp/difference.js';
import * as React from 'react';
import {
joinThreadActionTypes,
joinThread,
newThreadActionTypes,
} from 'lib/actions/thread-actions.js';
import SWMansionIcon from 'lib/components/SWMansionIcon.react.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import { threadInfoSelector } from 'lib/selectors/thread-selectors.js';
import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js';
import {
getTypeaheadUserSuggestions,
getTypeaheadRegexMatches,
getMentionsCandidates,
} from 'lib/shared/mention-utils.js';
import type { TypeaheadMatchedStrings } from 'lib/shared/mention-utils.js';
import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js';
import {
threadHasPermission,
viewerIsMember,
threadFrozenDueToViewerBlock,
threadActualMembers,
checkIfDefaultMembersAreVoiced,
} from 'lib/shared/thread-utils.js';
import type { CalendarQuery } from 'lib/types/entry-types.js';
import type { LoadingStatus } from 'lib/types/loading-types.js';
import { messageTypes } from 'lib/types/message-types-enum.js';
import { threadPermissions } from 'lib/types/thread-permission-types.js';
import {
type ThreadInfo,
type ClientThreadJoinRequest,
type ThreadJoinPayload,
type RelativeMemberInfo,
} from 'lib/types/thread-types.js';
import { type UserInfos } from 'lib/types/user-types.js';
import {
type DispatchActionPromise,
useServerCall,
useDispatchActionPromise,
} from 'lib/utils/action-utils.js';
import css from './chat-input-bar.css';
import TypeaheadTooltip from './typeahead-tooltip.react.js';
import Button from '../components/button.react.js';
import {
type InputState,
type PendingMultimediaUpload,
} from '../input/input-state.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { allowedMimeTypeString } from '../media/file-utils.js';
import Multimedia from '../media/multimedia.react.js';
import { useSelector } from '../redux/redux-utils.js';
import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js';
import { webTypeaheadRegex } from '../utils/typeahead-utils.js';
type BaseProps = {
+threadInfo: ThreadInfo,
+inputState: InputState,
};
type Props = {
...BaseProps,
+viewerID: ?string,
+joinThreadLoadingStatus: LoadingStatus,
+threadCreationInProgress: boolean,
+calendarQuery: () => CalendarQuery,
+nextLocalID: number,
+isThreadActive: boolean,
+userInfos: UserInfos,
+dispatchActionPromise: DispatchActionPromise,
+joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>,
+typeaheadMatchedStrings: ?TypeaheadMatchedStrings,
+suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>,
+parentThreadInfo: ?ThreadInfo,
};
class ChatInputBar extends React.PureComponent<Props> {
textarea: ?HTMLTextAreaElement;
multimediaInput: ?HTMLInputElement;
componentDidMount() {
this.updateHeight();
if (this.props.isThreadActive) {
this.addReplyListener();
}
}
componentWillUnmount() {
if (this.props.isThreadActive) {
this.removeReplyListener();
}
}
componentDidUpdate(prevProps: Props) {
if (this.props.isThreadActive && !prevProps.isThreadActive) {
this.addReplyListener();
} else if (!this.props.isThreadActive && prevProps.isThreadActive) {
this.removeReplyListener();
}
const { inputState } = this.props;
const prevInputState = prevProps.inputState;
if (inputState.draft !== prevInputState.draft) {
this.updateHeight();
}
if (
inputState.draft !== prevInputState.draft ||
inputState.textCursorPosition !== prevInputState.textCursorPosition
) {
inputState.setTypeaheadState({
canBeVisible: true,
});
}
const curUploadIDs = ChatInputBar.unassignedUploadIDs(
inputState.pendingUploads,
);
const prevUploadIDs = ChatInputBar.unassignedUploadIDs(
prevInputState.pendingUploads,
);
if (
this.multimediaInput &&
_difference(prevUploadIDs)(curUploadIDs).length > 0
) {
// Whenever a pending upload is removed, we reset the file
// HTMLInputElement's value field, so that if the same upload occurs again
// the onChange call doesn't get filtered
this.multimediaInput.value = '';
} else if (
this.textarea &&
_difference(curUploadIDs)(prevUploadIDs).length > 0
) {
// Whenever a pending upload is added, we focus the textarea
this.textarea.focus();
return;
}
if (
(this.props.threadInfo.id !== prevProps.threadInfo.id ||
(inputState.textCursorPosition !== prevInputState.textCursorPosition &&
this.textarea?.selectionStart === this.textarea?.selectionEnd)) &&
this.textarea
) {
this.textarea.focus();
this.textarea?.setSelectionRange(
inputState.textCursorPosition,
inputState.textCursorPosition,
'none',
);
}
}
static unassignedUploadIDs(
pendingUploads: $ReadOnlyArray<PendingMultimediaUpload>,
) {
return pendingUploads
.filter(
(pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID,
)
.map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID);
}
updateHeight() {
const textarea = this.textarea;
if (textarea) {
textarea.style.height = 'auto';
const newHeight = Math.min(textarea.scrollHeight, 150);
textarea.style.height = `${newHeight}px`;
}
}
addReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in addReplyListener',
);
this.props.inputState.addReplyListener(this.focusAndUpdateText);
}
removeReplyListener() {
invariant(
this.props.inputState,
'inputState should be set in removeReplyListener',
);
this.props.inputState.removeReplyListener(this.focusAndUpdateText);
}
render() {
const isMember = viewerIsMember(this.props.threadInfo);
const canJoin = threadHasPermission(
this.props.threadInfo,
threadPermissions.JOIN_THREAD,
);
let joinButton = null;
if (!isMember && canJoin && !this.props.threadCreationInProgress) {
let buttonContent;
if (this.props.joinThreadLoadingStatus === 'loading') {
buttonContent = (
<LoadingIndicator
status={this.props.joinThreadLoadingStatus}
size="medium"
color="white"
/>
);
} else {
buttonContent = (
<>
<SWMansionIcon icon="plus" size={24} />
<p className={css.joinButtonText}>Join Chat</p>
</>
);
}
joinButton = (
<div className={css.joinButtonContainer}>
<Button
variant="filled"
buttonColor={{ backgroundColor: `#${this.props.threadInfo.color}` }}
onClick={this.onClickJoin}
>
{buttonContent}
</Button>
</div>
);
}
const { pendingUploads, cancelPendingUpload } = this.props.inputState;
const multimediaPreviews = pendingUploads.map(pendingUpload => {
let mediaSource;
if (
pendingUpload.mediaType !== 'encrypted_photo' &&
pendingUpload.mediaType !== 'encrypted_video'
) {
mediaSource = {
type: pendingUpload.mediaType,
uri: pendingUpload.uri,
};
} else {
invariant(
pendingUpload.encryptionKey,
'encryptionKey should be set for encrypted media',
);
mediaSource = {
type: pendingUpload.mediaType,
holder: pendingUpload.uri,
encryptionKey: pendingUpload.encryptionKey,
};
}
return (
<Multimedia
mediaSource={mediaSource}
pendingUpload={pendingUpload}
remove={cancelPendingUpload}
multimediaCSSClass={css.multimedia}
multimediaImageCSSClass={css.multimediaImage}
key={pendingUpload.localID}
/>
);
});
const previews =
multimediaPreviews.length > 0 ? (
<div className={css.previews}>{multimediaPreviews}</div>
) : null;
let content;
// If the thread is created by somebody else while the viewer is attempting
// to create it, the threadInfo might be modified in-place and won't
// list the viewer as a member, which will end up hiding the input. In
// this case, we will assume that our creation action will get translated,
// into a join and as long as members are voiced, we can show the input.
const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced(
this.props.threadInfo,
);
let sendButton;
if (this.props.inputState.draft.length) {
sendButton = (
<a onClick={this.onSend} className={css.sendButton}>
<SWMansionIcon
icon="send-2"
size={22}
color={`#${this.props.threadInfo.color}`}
/>
</a>
);
}
if (
threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) ||
(this.props.threadCreationInProgress && defaultMembersAreVoiced)
) {
content = (
<div className={css.inputBarWrapper}>
<a className={css.multimediaUpload} onClick={this.onMultimediaClick}>
<input
type="file"
onChange={this.onMultimediaFileChange}
ref={this.multimediaInputRef}
accept={allowedMimeTypeString}
multiple
/>
<SWMansionIcon
icon="image-1"
size={22}
color={`#${this.props.threadInfo.color}`}
disableFill
/>
</a>
<div className={css.inputBarTextInput}>
<textarea
rows="1"
placeholder="Type your message"
value={this.props.inputState.draft}
onChange={this.onChangeMessageText}
onKeyDown={this.onKeyDown}
onClick={this.onClickTextarea}
onSelect={this.onSelectTextarea}
ref={this.textareaRef}
autoFocus
/>
</div>
{sendButton}
</div>
);
} else if (
threadFrozenDueToViewerBlock(
this.props.threadInfo,
this.props.viewerID,
this.props.userInfos,
) &&
threadActualMembers(this.props.threadInfo.members).length === 2
) {
content = (
<span className={css.explanation}>
- You can&apos;t send messages to a user that you&apos;ve blocked.
+ You can&rsquo;t send messages to a user that you&rsquo;ve blocked.
</span>
);
} else if (isMember) {
content = (
<span className={css.explanation}>
- You don&apos;t have permission to send messages.
+ You don&rsquo;t have permission to send messages.
</span>
);
} else if (defaultMembersAreVoiced && canJoin) {
content = null;
} else {
content = (
<span className={css.explanation}>
- You don&apos;t have permission to send messages.
+ You don&rsquo;t have permission to send messages.
</span>
);
}
let typeaheadTooltip;
if (
this.props.inputState.typeaheadState.canBeVisible &&
this.props.suggestedUsers.length > 0 &&
this.props.typeaheadMatchedStrings &&
this.textarea
) {
typeaheadTooltip = (
<TypeaheadTooltip
inputState={this.props.inputState}
textarea={this.textarea}
matchedStrings={this.props.typeaheadMatchedStrings}
suggestedUsers={this.props.suggestedUsers}
/>
);
}
return (
<div className={css.inputBar}>
{joinButton}
{previews}
{content}
{typeaheadTooltip}
</div>
);
}
textareaRef = (textarea: ?HTMLTextAreaElement) => {
this.textarea = textarea;
if (textarea) {
textarea.focus();
}
};
onChangeMessageText = (event: SyntheticEvent<HTMLTextAreaElement>) => {
this.props.inputState.setDraft(event.currentTarget.value);
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onClickTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
onSelectTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => {
this.props.inputState.setTextCursorPosition(
event.currentTarget.selectionStart,
);
};
focusAndUpdateText = (text: string) => {
// We need to call focus() first on Safari, otherwise the cursor
// ends up at the start instead of the end for some reason
const { textarea } = this;
invariant(textarea, 'textarea should be set');
textarea.focus();
// We reset the textarea to an empty string at the start so that the cursor
// always ends up at the end, even if the text doesn't actually change
textarea.value = '';
const currentText = this.props.inputState.draft;
if (!currentText.startsWith(text)) {
const prependedText = text.concat(currentText);
this.props.inputState.setDraft(prependedText);
textarea.value = prependedText;
} else {
textarea.value = currentText;
}
// The above strategies make sure the cursor is at the end,
// but we also need to make sure that we're scrolled to the bottom
textarea.scrollTop = textarea.scrollHeight;
};
onKeyDown = (event: SyntheticKeyboardEvent<HTMLTextAreaElement>) => {
const { accept, close, moveChoiceUp, moveChoiceDown } =
this.props.inputState.typeaheadState;
const actions = {
Enter: accept,
Tab: accept,
ArrowDown: moveChoiceDown,
ArrowUp: moveChoiceUp,
Escape: close,
};
if (
this.props.inputState.typeaheadState.canBeVisible &&
actions[event.key]
) {
event.preventDefault();
actions[event.key]();
} else if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.send();
}
};
onSend = (event: SyntheticEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.send();
};
send() {
let { nextLocalID } = this.props;
const text = trimMessage(this.props.inputState.draft);
if (text) {
this.dispatchTextMessageAction(text, nextLocalID);
nextLocalID++;
}
if (this.props.inputState.pendingUploads.length > 0) {
this.props.inputState.createMultimediaMessage(
nextLocalID,
this.props.threadInfo,
);
}
}
dispatchTextMessageAction(text: string, nextLocalID: number) {
this.props.inputState.setDraft('');
const localID = `${localIDPrefix}${nextLocalID}`;
const creatorID = this.props.viewerID;
invariant(creatorID, 'should have viewer ID in order to send a message');
this.props.inputState.sendTextMessage(
{
type: messageTypes.TEXT,
localID,
threadID: this.props.threadInfo.id,
text,
creatorID,
time: Date.now(),
},
this.props.threadInfo,
this.props.parentThreadInfo,
);
}
multimediaInputRef = (multimediaInput: ?HTMLInputElement) => {
this.multimediaInput = multimediaInput;
};
onMultimediaClick = () => {
if (this.multimediaInput) {
this.multimediaInput.click();
}
};
onMultimediaFileChange = async (
event: SyntheticInputEvent<HTMLInputElement>,
) => {
const result = await this.props.inputState.appendFiles([
...event.target.files,
]);
if (!result && this.multimediaInput) {
this.multimediaInput.value = '';
}
};
onClickJoin = () => {
this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction());
};
async joinAction() {
const query = this.props.calendarQuery();
return await this.props.joinThread({
threadID: this.props.threadInfo.id,
calendarQuery: {
startDate: query.startDate,
endDate: query.endDate,
filters: [
...query.filters,
{ type: 'threads', threadIDs: [this.props.threadInfo.id] },
],
},
});
}
}
const joinThreadLoadingStatusSelector = createLoadingStatusSelector(
joinThreadActionTypes,
);
const createThreadLoadingStatusSelector =
createLoadingStatusSelector(newThreadActionTypes);
const ConnectedChatInputBar: React.ComponentType<BaseProps> =
React.memo<BaseProps>(function ConnectedChatInputBar(props) {
const viewerID = useSelector(
state => state.currentUserInfo && state.currentUserInfo.id,
);
const nextLocalID = useSelector(state => state.nextLocalID);
const isThreadActive = useSelector(
state => props.threadInfo.id === state.navInfo.activeChatThreadID,
);
const userInfos = useSelector(state => state.userStore.userInfos);
const joinThreadLoadingStatus = useSelector(
joinThreadLoadingStatusSelector,
);
const createThreadLoadingStatus = useSelector(
createThreadLoadingStatusSelector,
);
const threadCreationInProgress = createThreadLoadingStatus === 'loading';
const calendarQuery = useSelector(nonThreadCalendarQuery);
const dispatchActionPromise = useDispatchActionPromise();
const callJoinThread = useServerCall(joinThread);
const userSearchIndex = useSelector(userStoreSearchIndex);
const { parentThreadID } = props.threadInfo;
const parentThreadInfo = useSelector(state =>
parentThreadID ? threadInfoSelector(state)[parentThreadID] : null,
);
const mentionsCandidates = getMentionsCandidates(
props.threadInfo,
parentThreadInfo,
);
const typeaheadRegexMatches = React.useMemo(
() =>
getTypeaheadRegexMatches(
props.inputState.draft,
{
start: props.inputState.textCursorPosition,
end: props.inputState.textCursorPosition,
},
webTypeaheadRegex,
),
[props.inputState.textCursorPosition, props.inputState.draft],
);
const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo(
() =>
typeaheadRegexMatches !== null
? {
textBeforeAtSymbol:
typeaheadRegexMatches.groups?.textPrefix ?? '',
usernamePrefix: typeaheadRegexMatches.groups?.username ?? '',
}
: null,
[typeaheadRegexMatches],
);
React.useEffect(() => {
if (props.inputState.typeaheadState.keepUpdatingThreadMembers) {
const setter = props.inputState.setTypeaheadState;
setter({
frozenMentionsCandidates: mentionsCandidates,
});
}
}, [
mentionsCandidates,
props.inputState.setTypeaheadState,
props.inputState.typeaheadState.keepUpdatingThreadMembers,
]);
const suggestedUsers: $ReadOnlyArray<RelativeMemberInfo> =
React.useMemo(() => {
if (!typeaheadMatchedStrings) {
return [];
}
return getTypeaheadUserSuggestions(
userSearchIndex,
props.inputState.typeaheadState.frozenMentionsCandidates,
viewerID,
typeaheadMatchedStrings.usernamePrefix,
);
}, [
userSearchIndex,
props.inputState.typeaheadState.frozenMentionsCandidates,
viewerID,
typeaheadMatchedStrings,
]);
return (
<ChatInputBar
{...props}
viewerID={viewerID}
joinThreadLoadingStatus={joinThreadLoadingStatus}
threadCreationInProgress={threadCreationInProgress}
calendarQuery={calendarQuery}
nextLocalID={nextLocalID}
isThreadActive={isThreadActive}
userInfos={userInfos}
dispatchActionPromise={dispatchActionPromise}
joinThread={callJoinThread}
typeaheadMatchedStrings={typeaheadMatchedStrings}
suggestedUsers={suggestedUsers}
parentThreadInfo={parentThreadInfo}
/>
);
});
export default ConnectedChatInputBar;
diff --git a/web/modals/chat/invalid-upload.react.js b/web/modals/chat/invalid-upload.react.js
index 8d9d724bb..5589f2138 100644
--- a/web/modals/chat/invalid-upload.react.js
+++ b/web/modals/chat/invalid-upload.react.js
@@ -1,15 +1,15 @@
// @flow
import * as React from 'react';
import Alert from '../alert.react.js';
function InvalidUploadModal(): React.Node {
return (
<Alert title="Invalid upload">
- We don&apos;t support that file type yet :(
+ We don&rsquo;t support that file type yet :(
</Alert>
);
}
export default InvalidUploadModal;
diff --git a/web/modals/terms-and-privacy-modal.react.js b/web/modals/terms-and-privacy-modal.react.js
index 347b65a8c..cde20c44a 100644
--- a/web/modals/terms-and-privacy-modal.react.js
+++ b/web/modals/terms-and-privacy-modal.react.js
@@ -1,96 +1,96 @@
// @flow
import * as React from 'react';
import {
policyAcknowledgment,
policyAcknowledgmentActionTypes,
} from 'lib/actions/user-actions.js';
import { policyTypes } from 'lib/facts/policies.js';
import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
import {
useDispatchActionPromise,
useServerCall,
} from 'lib/utils/action-utils.js';
import { acknowledgePolicy } from 'lib/utils/policy-acknowledge-utlis.js';
import Modal from './modal.react.js';
import css from './terms-and-privacy-modal.css';
import Button, { buttonThemes } from '../components/button.react.js';
import LoadingIndicator from '../loading-indicator.react.js';
import { useSelector } from '../redux/redux-utils.js';
const loadingStatusSelector = createLoadingStatusSelector(
policyAcknowledgmentActionTypes,
);
const disabledOnClose = () => undefined;
function TermsAndPrivacyModal(): React.Node {
const loading = useSelector(loadingStatusSelector);
const [acknowledgmentError, setAcknowledgmentError] = React.useState('');
const sendAcknowledgmentRequest = useServerCall(policyAcknowledgment);
const dispatchActionPromise = useDispatchActionPromise();
const onAccept = React.useCallback(() => {
acknowledgePolicy(
policyTypes.tosAndPrivacyPolicy,
dispatchActionPromise,
sendAcknowledgmentRequest,
setAcknowledgmentError,
);
}, [dispatchActionPromise, sendAcknowledgmentRequest]);
const buttonContent = React.useMemo(() => {
if (loading === 'loading') {
return <LoadingIndicator status="loading" size="medium" />;
}
return 'I accept';
}, [loading]);
return (
<Modal
withCloseButton={false}
modalHeaderCentered={true}
onClose={disabledOnClose}
name="Terms of Service and Privacy Policy"
size="large"
>
<div className={css.container}>
We recently updated our{' '}
<a
href="https://comm.app/terms"
target="_blank"
rel="noreferrer"
className={css.link}
>
Terms of Service
</a>
{' & '}
<a
href="https://comm.app/privacy"
target="_blank"
rel="noreferrer"
className={css.link}
>
Privacy Policy
</a>
- . In order to continue using Comm, we&apos;re asking you to read
+ . In order to continue using Comm, we&rsquo;re asking you to read
through, acknowledge, and accept the updated policies.
</div>
<div className={css.button}>
<Button
variant="filled"
buttonColor={buttonThemes.standard}
onClick={onAccept}
>
<div className={css.buttonContent}>{buttonContent}</div>
</Button>
<div className={css.error}>{acknowledgmentError}</div>
</div>
</Modal>
);
}
export default TermsAndPrivacyModal;

File Metadata

Mime Type
text/x-diff
Expires
Mon, Dec 23, 8:04 AM (1 d, 12 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2690615
Default Alt Text
(124 KB)

Event Timeline