workspace with decoupled nostr package
This commit is contained in:
3
packages/app/.babelrc
Normal file
3
packages/app/.babelrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": [["formatjs"]]
|
||||
}
|
7
packages/app/.dockerignore
Normal file
7
packages/app/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.github/
|
||||
.vscode/
|
||||
build/
|
||||
yarn-error.log
|
||||
.husky/
|
||||
.git/
|
13
packages/app/.eslintrc.cjs
Normal file
13
packages/app/.eslintrc.cjs
Normal file
@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint"],
|
||||
root: true,
|
||||
ignorePatterns: ["build/"],
|
||||
env: {
|
||||
browser: true,
|
||||
worker: true,
|
||||
commonjs: true,
|
||||
node: true,
|
||||
},
|
||||
};
|
40
packages/app/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
40
packages/app/.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
19
packages/app/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
19
packages/app/.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
18
packages/app/.github/workflows/docker.yaml
vendored
Normal file
18
packages/app/.github/workflows/docker.yaml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Docker build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/setup-qemu-action@v1
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
- name: Build the Docker image
|
||||
run: docker buildx build -t ghcr.io/${{ github.repository_owner }}/snort:latest --platform linux/amd64,linux/arm64 --push .
|
18
packages/app/.github/workflows/eslint.yaml
vendored
Normal file
18
packages/app/.github/workflows/eslint.yaml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Linting
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
formatting:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Check Eslint
|
||||
run: yarn eslint
|
18
packages/app/.github/workflows/formatting.yaml
vendored
Normal file
18
packages/app/.github/workflows/formatting.yaml
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
name: Formatting
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
formatting:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Check Formatting
|
||||
run: yarn prettier --check .
|
37
packages/app/.github/workflows/tagged.yml
vendored
Normal file
37
packages/app/.github/workflows/tagged.yml
vendored
Normal file
@ -0,0 +1,37 @@
|
||||
name: Docker build on tag
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: enabled
|
||||
TAG_FMT: "^refs/tags/(((.?[0-9]+){3,4}))$"
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- v[0-9]+.[0-9]+.[0-9]+
|
||||
- v[0-9]+.[0-9]+.[0-9]+-*
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Set env variables
|
||||
run: echo "TAG=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/setup-qemu-action@v1
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
- name: Build the Docker image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t ghcr.io/${{ github.repository_owner }}/snort:$TAG \
|
||||
--cache-from "type=local,src=/tmp/.buildx-cache" \
|
||||
--cache-to "type=local,dest=/tmp/.buildx-cache" \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--push .
|
25
packages/app/.gitignore
vendored
Normal file
25
packages/app/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.idea
|
3
packages/app/.prettierignore
Normal file
3
packages/app/.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
build/
|
||||
.github/
|
||||
transifex.yml
|
5
packages/app/.prettierrc.json
Normal file
5
packages/app/.prettierrc.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"bracketSameLine": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
674
packages/app/LICENSE
Normal file
674
packages/app/LICENSE
Normal file
@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
33
packages/app/README.md
Normal file
33
packages/app/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
## Snort
|
||||
|
||||
Snort is a nostr UI built with React, Snort intends to be fast and effecient
|
||||
|
||||
Snort supports the following NIP's:
|
||||
|
||||
- [x] NIP-01: Basic protocol flow description
|
||||
- [x] NIP-02: Contact List and Petnames (No petname support)
|
||||
- [ ] NIP-03: OpenTimestamps Attestations for Events
|
||||
- [x] NIP-04: Encrypted Direct Message
|
||||
- [x] NIP-05: Mapping Nostr keys to DNS-based internet identifiers
|
||||
- [ ] NIP-06: Basic key derivation from mnemonic seed phrase
|
||||
- [x] NIP-07: `window.nostr` capability for web browsers
|
||||
- [x] NIP-08: Handling Mentions
|
||||
- [x] NIP-09: Event Deletion
|
||||
- [x] NIP-10: Conventions for clients' use of `e` and `p` tags in text events
|
||||
- [x] NIP-11: Relay Information Document
|
||||
- [x] NIP-12: Generic Tag Queries
|
||||
- [ ] NIP-13: Proof of Work
|
||||
- [ ] NIP-14: Subject tag in text events
|
||||
- [x] NIP-15: End of Stored Events Notice
|
||||
- [x] NIP-19: bech32-encoded entities
|
||||
- [x] NIP-20: Command Results
|
||||
- [x] NIP-21: `nostr:` Protocol handler (`web+nostr`)
|
||||
- [x] NIP-25: Reactions
|
||||
- [x] NIP-26: Delegated Event Signing (Display delegated signings only)
|
||||
- [ ] NIP-28: Public Chat
|
||||
- [ ] NIP-36: Sensitive Content
|
||||
- [ ] NIP-40: Expiration Timestamp
|
||||
- [ ] NIP-42: Authentication of clients to relays
|
||||
- [x] NIP-50: Search
|
||||
- [x] NIP-51: Lists
|
||||
- [x] NIP-65: Relay List Metadata
|
4
packages/app/config-overrides.js
Normal file
4
packages/app/config-overrides.js
Normal file
@ -0,0 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { useBabelRc, override } = require("customize-cra");
|
||||
|
||||
module.exports = override(useBabelRc());
|
28
packages/app/d.ts
Normal file
28
packages/app/d.ts
Normal file
@ -0,0 +1,28 @@
|
||||
declare module "*.jpg" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.svg" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "*.webp" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
|
||||
declare module "light-bolt11-decoder" {
|
||||
export function decode(pr?: string): ParsedInvoice;
|
||||
|
||||
export interface ParsedInvoice {
|
||||
paymentRequest: string;
|
||||
sections: Section[];
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
name: string;
|
||||
value: string | Uint8Array | number | undefined;
|
||||
}
|
||||
}
|
96
packages/app/package.json
Normal file
96
packages/app/package.json
Normal file
@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@snort/app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@jukben/emoji-search": "^2.0.1",
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"@noble/secp256k1": "^1.7.0",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@reduxjs/toolkit": "^1.9.1",
|
||||
"@szhsin/react-menu": "^3.3.1",
|
||||
"@types/jest": "^29.2.5",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@types/webscopeio__react-textarea-autocomplete": "^4.7.2",
|
||||
"@webscopeio/react-textarea-autocomplete": "^4.9.2",
|
||||
"bech32": "^2.0.0",
|
||||
"dexie": "^3.2.2",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"light-bolt11-decoder": "^2.1.0",
|
||||
"qr-code-styling": "^1.6.0-rc.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-intersection-observer": "^9.4.1",
|
||||
"react-intl": "^6.2.8",
|
||||
"react-markdown": "^8.0.4",
|
||||
"react-query": "^3.39.2",
|
||||
"react-redux": "^8.0.5",
|
||||
"react-router-dom": "^6.5.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
"react-twitter-embed": "^4.0.4",
|
||||
"typescript": "^4.9.4",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"uuid": "^9.0.0",
|
||||
"workbox-background-sync": "^6.4.2",
|
||||
"workbox-broadcast-update": "^6.4.2",
|
||||
"workbox-cacheable-response": "^6.4.2",
|
||||
"workbox-core": "^6.4.2",
|
||||
"workbox-expiration": "^6.4.2",
|
||||
"workbox-google-analytics": "^6.4.2",
|
||||
"workbox-navigation-preload": "^6.4.2",
|
||||
"workbox-precaching": "^6.4.2",
|
||||
"workbox-range-requests": "^6.4.2",
|
||||
"workbox-routing": "^6.4.2",
|
||||
"workbox-strategies": "^6.4.2",
|
||||
"workbox-streams": "^6.4.2",
|
||||
"@snort/nostr": "^1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-app-rewired start",
|
||||
"build": "react-app-rewired build",
|
||||
"test": "react-app-rewired test",
|
||||
"eject": "react-scripts eject",
|
||||
"intl-extract": "formatjs extract 'src/**/*.ts*' --ignore='**/*.d.ts' --out-file src/lang.json --format transifex --flatten true",
|
||||
"intl-compile": "formatjs compile src/lang.json --out-file src/translations/en.json --format transifex",
|
||||
"transifex": "formatjs compile src/translations/$LNG.json --out-file src/translations/$LNG.json --format transifex",
|
||||
"format": "prettier --write .",
|
||||
"eslint": "eslint .",
|
||||
"prepare": "cd ../.. && husky install"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.0.1",
|
||||
"babel-plugin-formatjs": "^10.3.36",
|
||||
"customize-cra": "^1.0.0",
|
||||
"husky": ">=6",
|
||||
"lint-staged": ">=10",
|
||||
"prettier": "2.8.3",
|
||||
"react-app-rewired": "^2.2.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,css,md}": "prettier --write"
|
||||
}
|
||||
}
|
BIN
packages/app/public/favicon.ico
Normal file
BIN
packages/app/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
24
packages/app/public/index.html
Normal file
24
packages/app/public/index.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Fast nostr web ui" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; child-src 'none'; worker-src 'self'; frame-src youtube.com www.youtube.com https://platform.twitter.com https://embed.tidal.com https://w.soundcloud.com https://www.mixcloud.com https://open.spotify.com https://player.twitch.tv https://embed.music.apple.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; connect-src wss://* ws://*:* 'self' https://*; img-src * data:; font-src https://fonts.gstatic.com; media-src *; script-src 'self' https://static.cloudflareinsights.com https://platform.twitter.com https://embed.tidal.com;" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/nostrich_512.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>snort.social - Nostr interface</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
21
packages/app/public/manifest.json
Normal file
21
packages/app/public/manifest.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"short_name": "Snort",
|
||||
"name": "snort.social - Nostr interface",
|
||||
"description": "Fast nostr web ui",
|
||||
"icons": [
|
||||
{
|
||||
"src": "nostrich_256.png",
|
||||
"type": "image/png",
|
||||
"sizes": "256x256"
|
||||
},
|
||||
{
|
||||
"src": "nostrich_512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000"
|
||||
}
|
BIN
packages/app/public/nostrich_256.png
Normal file
BIN
packages/app/public/nostrich_256.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
BIN
packages/app/public/nostrich_512.png
Normal file
BIN
packages/app/public/nostrich_512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 528 KiB |
BIN
packages/app/public/nostrich_orig.jpeg
Normal file
BIN
packages/app/public/nostrich_orig.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 771 KiB |
3
packages/app/public/robots.txt
Normal file
3
packages/app/public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
156
packages/app/src/Const.ts
Normal file
156
packages/app/src/Const.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
|
||||
/**
|
||||
* Add-on api for snort features
|
||||
*/
|
||||
export const ApiHost = "https://api.snort.social";
|
||||
|
||||
/**
|
||||
* LibreTranslate endpoint
|
||||
*/
|
||||
export const TranslateHost = "https://translate.snort.social";
|
||||
|
||||
/**
|
||||
* Void.cat file upload service url
|
||||
*/
|
||||
export const VoidCatHost = "https://void.cat";
|
||||
|
||||
/**
|
||||
* Kierans pubkey
|
||||
*/
|
||||
export const KieranPubKey = "npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49";
|
||||
|
||||
/**
|
||||
* Official snort account
|
||||
*/
|
||||
export const SnortPubKey = "npub1sn0rtcjcf543gj4wsg7fa59s700d5ztys5ctj0g69g2x6802npjqhjjtws";
|
||||
|
||||
/**
|
||||
* Websocket re-connect timeout
|
||||
*/
|
||||
export const DefaultConnectTimeout = 2000;
|
||||
|
||||
/**
|
||||
* How long profile cache should be considered valid for
|
||||
*/
|
||||
export const ProfileCacheExpire = 1_000 * 60 * 5;
|
||||
|
||||
/**
|
||||
* Default bootstrap relays
|
||||
*/
|
||||
export const DefaultRelays = new Map<string, RelaySettings>([
|
||||
["wss://relay.snort.social", { read: true, write: true }],
|
||||
["wss://nostr.wine", { read: true, write: false }],
|
||||
["wss://eden.nostr.land", { read: true, write: false }],
|
||||
["wss://atlas.nostr.land", { read: true, write: false }],
|
||||
["wss://relay.orangepill.dev", { read: true, write: false }],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Default search relays
|
||||
*/
|
||||
export const SearchRelays = new Map<string, RelaySettings>([["wss://relay.nostr.band", { read: true, write: false }]]);
|
||||
|
||||
/**
|
||||
* List of recommended follows for new users
|
||||
*/
|
||||
export const RecommendedFollows = [
|
||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2", // jack
|
||||
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", // fiatjaf
|
||||
"020f2d21ae09bf35fcdfb65decf1478b846f5f728ab30c5eaabcd6d081a81c3e", // adam3us
|
||||
"6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93", // gigi
|
||||
"63fe6318dc58583cfe16810f86dd09e18bfd76aabc24a0081ce2856f330504ed", // Kieran
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", // jb55
|
||||
"e33fe65f1fde44c6dc17eeb38fdad0fceaf1cae8722084332ed1e32496291d42", // wiz
|
||||
"00000000827ffaa94bfea288c3dfce4422c794fbb96625b6b31e9049f729d700", // cameri
|
||||
"A341F45FF9758F570A21B000C17D4E53A3A497C8397F26C0E6D61E5ACFFC7A98", // Saylor
|
||||
"E88A691E98D9987C964521DFF60025F60700378A4879180DCBBB4A5027850411", // NVK
|
||||
"C4EABAE1BE3CF657BC1855EE05E69DE9F059CB7A059227168B80B89761CBC4E0", // jackmallers
|
||||
"85080D3BAD70CCDCD7F74C29A44F55BB85CBCD3DD0CBB957DA1D215BDB931204", // preston
|
||||
"C49D52A573366792B9A6E4851587C28042FB24FA5625C6D67B8C95C8751ACA15", // holdonaut
|
||||
"83E818DFBECCEA56B0F551576B3FD39A7A50E1D8159343500368FA085CCD964B", // jeffbooth
|
||||
"3F770D65D3A764A9C5CB503AE123E62EC7598AD035D836E2A810F3877A745B24", // DerekRoss
|
||||
"472F440F29EF996E92A186B8D320FF180C855903882E59D50DE1B8BD5669301E", // MartyBent
|
||||
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", // yegorpetrov
|
||||
"04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9", // ODELL
|
||||
"7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", // verbiricha
|
||||
"52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd", // semisol
|
||||
];
|
||||
|
||||
/**
|
||||
* Regex to match email address
|
||||
*/
|
||||
export const EmailRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
/**
|
||||
* Generic URL regex
|
||||
*/
|
||||
export const UrlRegex =
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/((?:http|ftp|https):\/\/(?:[\w+?\.\w+])+(?:[a-zA-Z0-9\~\!\@\#\$\%\^\&\*\(\)_\-\=\+\\\/\?\.\:\;\'\,]*)?)/i;
|
||||
|
||||
/**
|
||||
* Extract file extensions regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const FileExtensionRegex = /\.([\w]+)$/i;
|
||||
|
||||
/**
|
||||
* Extract note reactions regex
|
||||
*/
|
||||
export const MentionRegex = /(#\[\d+\])/gi;
|
||||
|
||||
/**
|
||||
* Simple lightning invoice regex
|
||||
*/
|
||||
export const InvoiceRegex = /(lnbc\w+)/i;
|
||||
|
||||
/**
|
||||
* YouTube URL regex
|
||||
*/
|
||||
export const YoutubeUrlRegex =
|
||||
/(?:https?:\/\/)?(?:www|m\.)?(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
|
||||
|
||||
/**
|
||||
* Tweet Regex
|
||||
*/
|
||||
export const TweetUrlRegex = /https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(?:es)?\/(\d+)/;
|
||||
|
||||
/**
|
||||
* Hashtag regex
|
||||
*/
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
export const HashtagRegex = /(#[^\s!@#$%^&*()=+.\/,\[{\]};:'"?><]+)/;
|
||||
|
||||
/**
|
||||
* Tidal share link regex
|
||||
*/
|
||||
export const TidalRegex = /tidal\.com\/(?:browse\/)?(\w+)\/([a-z0-9-]+)/i;
|
||||
|
||||
/**
|
||||
* SoundCloud regex
|
||||
*/
|
||||
export const SoundCloudRegex = /soundcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
/**
|
||||
* Mixcloud regex
|
||||
*/
|
||||
export const MixCloudRegex = /mixcloud\.com\/(?!live)([a-zA-Z0-9]+)\/([a-zA-Z0-9-]+)/;
|
||||
|
||||
/**
|
||||
* Spotify embed regex
|
||||
*/
|
||||
export const SpotifyRegex = /open\.spotify\.com\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/;
|
||||
|
||||
/**
|
||||
* Twitch embed regex
|
||||
*/
|
||||
export const TwitchRegex = /twitch.tv\/([a-z0-9_]+$)/i;
|
||||
|
||||
/**
|
||||
* Apple Music embed regex
|
||||
*/
|
||||
export const AppleMusicRegex =
|
||||
/music\.apple\.com\/([a-z]{2}\/)?(?:album|playlist)\/[\w\d-]+\/([.a-zA-Z0-9-]+)(?:\?i=\d+)?/i;
|
42
packages/app/src/Db/index.ts
Normal file
42
packages/app/src/Db/index.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import Dexie, { Table } from "dexie";
|
||||
import { TaggedRawEvent, u256 } from "@snort/nostr";
|
||||
import { MetadataCache } from "State/Users";
|
||||
import { hexToBech32 } from "Util";
|
||||
|
||||
export const NAME = "snortDB";
|
||||
export const VERSION = 3;
|
||||
|
||||
export interface SubCache {
|
||||
id: string;
|
||||
ids: u256[];
|
||||
until?: number;
|
||||
since?: number;
|
||||
}
|
||||
|
||||
const STORES = {
|
||||
users: "++pubkey, name, display_name, picture, nip05, npub",
|
||||
events: "++id, pubkey, created_at",
|
||||
feeds: "++id",
|
||||
};
|
||||
|
||||
export class SnortDB extends Dexie {
|
||||
users!: Table<MetadataCache>;
|
||||
events!: Table<TaggedRawEvent>;
|
||||
feeds!: Table<SubCache>;
|
||||
|
||||
constructor() {
|
||||
super(NAME);
|
||||
this.version(VERSION)
|
||||
.stores(STORES)
|
||||
.upgrade(async tx => {
|
||||
await tx
|
||||
.table("users")
|
||||
.toCollection()
|
||||
.modify(user => {
|
||||
user.npub = hexToBech32("npub", user.pubkey);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new SnortDB();
|
16
packages/app/src/Element/AppleMusicEmbed.tsx
Normal file
16
packages/app/src/Element/AppleMusicEmbed.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const AppleMusicEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace("music.apple.com", "embed.music.apple.com");
|
||||
const isSongLink = /\?i=\d+$/.test(convertedUrl);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
allow="autoplay *; encrypted-media *; fullscreen *; clipboard-write"
|
||||
frameBorder="0"
|
||||
height={isSongLink ? 175 : 450}
|
||||
style={{ width: "100%", maxWidth: 660, overflow: "hidden", background: "transparent" }}
|
||||
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
|
||||
src={convertedUrl}></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppleMusicEmbed;
|
31
packages/app/src/Element/AsyncButton.tsx
Normal file
31
packages/app/src/Element/AsyncButton.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface AsyncButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
onClick(e: React.MouseEvent): Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function AsyncButton(props: AsyncButtonProps) {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
async function handle(e: React.MouseEvent) {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (typeof props.onClick === "function") {
|
||||
const f = props.onClick(e);
|
||||
if (f instanceof Promise) {
|
||||
await f;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" disabled={loading} {...props} onClick={handle}>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
19
packages/app/src/Element/Avatar.css
Normal file
19
packages/app/src/Element/Avatar.css
Normal file
@ -0,0 +1,19 @@
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
height: 210px;
|
||||
width: 210px;
|
||||
background-image: var(--img-url);
|
||||
border: 1px solid transparent;
|
||||
background-origin: border-box;
|
||||
background-clip: content-box, border-box;
|
||||
background-size: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar[data-domain="snort.social"] {
|
||||
background-image: var(--img-url), var(--snort-gradient);
|
||||
}
|
||||
|
||||
.avatar[data-domain="strike.army"] {
|
||||
background-image: var(--img-url), var(--strike-army-gradient);
|
||||
}
|
25
packages/app/src/Element/Avatar.tsx
Normal file
25
packages/app/src/Element/Avatar.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import "./Avatar.css";
|
||||
import Nostrich from "nostrich.webp";
|
||||
import { CSSProperties, useEffect, useState } from "react";
|
||||
import type { UserMetadata } from "@snort/nostr";
|
||||
import useImgProxy from "Feed/ImgProxy";
|
||||
|
||||
const Avatar = ({ user, ...rest }: { user?: UserMetadata; onClick?: () => void }) => {
|
||||
const [url, setUrl] = useState<string>(Nostrich);
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.picture) {
|
||||
proxy(user.picture, 120)
|
||||
.then(a => setUrl(a))
|
||||
.catch(console.warn);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const backgroundImage = `url(${url})`;
|
||||
const style = { "--img-url": backgroundImage } as CSSProperties;
|
||||
const domain = user?.nip05 && user.nip05.split("@")[1];
|
||||
return <div {...rest} style={style} className="avatar" data-domain={domain?.toLowerCase()}></div>;
|
||||
};
|
||||
|
||||
export default Avatar;
|
21
packages/app/src/Element/BackButton.css
Normal file
21
packages/app/src/Element/BackButton.css
Normal file
@ -0,0 +1,21 @@
|
||||
.back-button {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--highlight);
|
||||
font-weight: 400;
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.back-button svg {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: none;
|
||||
color: var(--font-color);
|
||||
text-decoration: underline;
|
||||
}
|
29
packages/app/src/Element/BackButton.tsx
Normal file
29
packages/app/src/Element/BackButton.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import "./BackButton.css";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import ArrowBack from "Icons/ArrowBack";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BackButtonProps {
|
||||
text?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
const BackButton = ({ text, onClick }: BackButtonProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const onClickHandler = () => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="back-button" type="button" onClick={onClickHandler}>
|
||||
<ArrowBack />
|
||||
{text || formatMessage(messages.Back)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
24
packages/app/src/Element/BlockButton.tsx
Normal file
24
packages/app/src/Element/BlockButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BlockButtonProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
const BlockButton = ({ pubkey }: BlockButtonProps) => {
|
||||
const { block, unblock, isBlocked } = useModeration();
|
||||
return isBlocked(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unblock(pubkey)}>
|
||||
<FormattedMessage {...messages.Unblock} />
|
||||
</button>
|
||||
) : (
|
||||
<button className="secondary" type="button" onClick={() => block(pubkey)}>
|
||||
<FormattedMessage {...messages.Block} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockButton;
|
42
packages/app/src/Element/BlockList.tsx
Normal file
42
packages/app/src/Element/BlockList.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import BlockButton from "Element/BlockButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BlockListProps {
|
||||
variant: "muted" | "blocked";
|
||||
}
|
||||
|
||||
export default function BlockList({ variant }: BlockListProps) {
|
||||
const { blocked, muted } = useModeration();
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
{variant === "muted" && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: muted.length }} />
|
||||
</h4>
|
||||
{muted.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{variant === "blocked" && (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.BlockCount} values={{ n: blocked.length }} />
|
||||
</h4>
|
||||
{blocked.map(a => {
|
||||
return (
|
||||
<ProfilePreview actions={<BlockButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
61
packages/app/src/Element/Bookmarks.tsx
Normal file
61
packages/app/src/Element/Bookmarks.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useState, useMemo, ChangeEvent } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { dedupeByPubkey } from "Util";
|
||||
import Note from "Element/Note";
|
||||
import { HexKey, TaggedRawEvent } from "Nostr";
|
||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface BookmarksProps {
|
||||
pubkey: HexKey;
|
||||
bookmarks: TaggedRawEvent[];
|
||||
related: TaggedRawEvent[];
|
||||
}
|
||||
|
||||
const Bookmarks = ({ pubkey, bookmarks, related }: BookmarksProps) => {
|
||||
const [onlyPubkey, setOnlyPubkey] = useState<HexKey | "all">("all");
|
||||
const loginPubKey = useSelector((s: RootState) => s.login.publicKey);
|
||||
const ps = useMemo(() => {
|
||||
return dedupeByPubkey(bookmarks).map(ev => ev.pubkey);
|
||||
}, [bookmarks]);
|
||||
const profiles = useUserProfiles(ps);
|
||||
|
||||
function renderOption(p: HexKey) {
|
||||
const profile = profiles?.get(p);
|
||||
return profile ? <option value={p}>{profile?.display_name || profile?.name}</option> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="icon-title">
|
||||
<select
|
||||
disabled={ps.length <= 1}
|
||||
value={onlyPubkey}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => setOnlyPubkey(e.target.value)}>
|
||||
<option value="all">
|
||||
<FormattedMessage {...messages.All} />
|
||||
</option>
|
||||
{ps.map(renderOption)}
|
||||
</select>
|
||||
</div>
|
||||
{bookmarks
|
||||
.filter(b => (onlyPubkey === "all" ? true : b.pubkey === onlyPubkey))
|
||||
.map(n => {
|
||||
return (
|
||||
<Note
|
||||
key={n.id}
|
||||
data={n}
|
||||
related={related}
|
||||
options={{ showTime: false, showBookmarked: true, canUnbookmark: loginPubKey === pubkey }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmarks;
|
56
packages/app/src/Element/Collapsed.tsx
Normal file
56
packages/app/src/Element/Collapsed.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useState, ReactNode } from "react";
|
||||
|
||||
import ChevronDown from "Icons/ChevronDown";
|
||||
import ShowMore from "Element/ShowMore";
|
||||
|
||||
interface CollapsedProps {
|
||||
text?: string;
|
||||
children: ReactNode;
|
||||
collapsed: boolean;
|
||||
setCollapsed(b: boolean): void;
|
||||
}
|
||||
|
||||
const Collapsed = ({ text, children, collapsed, setCollapsed }: CollapsedProps) => {
|
||||
return collapsed ? (
|
||||
<div className="collapsed">
|
||||
<ShowMore text={text} onClick={() => setCollapsed(false)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="uncollapsed">{children}</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CollapsedIconProps {
|
||||
icon: ReactNode;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export const CollapsedIcon = ({ icon, collapsed }: CollapsedIconProps) => {
|
||||
return collapsed ? <div className="collapsed">{icon}</div> : <div className="uncollapsed">{icon}</div>;
|
||||
};
|
||||
|
||||
interface CollapsedSectionProps {
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const CollapsedSection = ({ title, children }: CollapsedSectionProps) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const icon = (
|
||||
<div className={`collapse-icon ${collapsed ? "" : "flip"}`} onClick={() => setCollapsed(!collapsed)}>
|
||||
<ChevronDown />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="collapsable-section">
|
||||
<h3 onClick={() => setCollapsed(!collapsed)}>{title}</h3>
|
||||
<CollapsedIcon icon={icon} collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
{collapsed ? null : children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collapsed;
|
14
packages/app/src/Element/Copy.css
Normal file
14
packages/app/src/Element/Copy.css
Normal file
@ -0,0 +1,14 @@
|
||||
.copy {
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy .body {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--font-color);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.copy .icon {
|
||||
margin-bottom: -4px;
|
||||
}
|
23
packages/app/src/Element/Copy.tsx
Normal file
23
packages/app/src/Element/Copy.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import "./Copy.css";
|
||||
import Check from "Icons/Check";
|
||||
import CopyIcon from "Icons/Copy";
|
||||
import { useCopy } from "useCopy";
|
||||
|
||||
export interface CopyProps {
|
||||
text: string;
|
||||
maxSize?: number;
|
||||
}
|
||||
export default function Copy({ text, maxSize = 32 }: CopyProps) {
|
||||
const { copy, copied } = useCopy();
|
||||
const sliceLength = maxSize / 2;
|
||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
||||
<span className="body">{trimmed}</span>
|
||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{copied ? <Check width={13} height={13} /> : <CopyIcon width={13} height={13} />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/DM.css
Normal file
23
packages/app/src/Element/DM.css
Normal file
@ -0,0 +1,23 @@
|
||||
.dm {
|
||||
padding: 8px;
|
||||
background-color: var(--gray);
|
||||
margin-bottom: 5px;
|
||||
border-radius: 5px;
|
||||
width: fit-content;
|
||||
min-width: 100px;
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dm > div:first-child {
|
||||
color: var(--gray-light);
|
||||
font-size: small;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.dm.me {
|
||||
align-self: flex-end;
|
||||
background-color: var(--gray-secondary);
|
||||
}
|
61
packages/app/src/Element/DM.tsx
Normal file
61
packages/app/src/Element/DM.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import "./DM.css";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useIntl } from "react-intl";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { Event } from "@snort/nostr";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import Text from "Element/Text";
|
||||
import { setLastReadDm } from "Pages/MessagesPage";
|
||||
import { RootState } from "State/Store";
|
||||
import { HexKey, TaggedRawEvent } from "@snort/nostr";
|
||||
import { incDmInteraction } from "State/Login";
|
||||
import { unwrap } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export type DMProps = {
|
||||
data: TaggedRawEvent;
|
||||
};
|
||||
|
||||
export default function DM(props: DMProps) {
|
||||
const dispatch = useDispatch();
|
||||
const pubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const publisher = useEventPublisher();
|
||||
const [content, setContent] = useState("Loading...");
|
||||
const [decrypted, setDecrypted] = useState(false);
|
||||
const { ref, inView } = useInView();
|
||||
const { formatMessage } = useIntl();
|
||||
const isMe = props.data.pubkey === pubKey;
|
||||
const otherPubkey = isMe ? pubKey : unwrap(props.data.tags.find(a => a[0] === "p")?.[1]);
|
||||
|
||||
async function decrypt() {
|
||||
const e = new Event(props.data);
|
||||
const decrypted = await publisher.decryptDm(e);
|
||||
setContent(decrypted || "<ERROR>");
|
||||
if (!isMe) {
|
||||
setLastReadDm(e.PubKey);
|
||||
dispatch(incDmInteraction());
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!decrypted && inView) {
|
||||
setDecrypted(true);
|
||||
decrypt().catch(console.error);
|
||||
}
|
||||
}, [inView, props.data]);
|
||||
|
||||
return (
|
||||
<div className={`flex dm f-col${isMe ? " me" : ""}`} ref={ref}>
|
||||
<div>
|
||||
<NoteTime from={props.data.created_at * 1000} fallback={formatMessage(messages.JustNow)} />
|
||||
</div>
|
||||
<div className="w-max">
|
||||
<Text content={content} tags={[]} users={new Map()} creator={otherPubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
2
packages/app/src/Element/FollowButton.css
Normal file
2
packages/app/src/Element/FollowButton.css
Normal file
@ -0,0 +1,2 @@
|
||||
.follow-button {
|
||||
}
|
39
packages/app/src/Element/FollowButton.tsx
Normal file
39
packages/app/src/Element/FollowButton.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import "./FollowButton.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import { parseId } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface FollowButtonProps {
|
||||
pubkey: HexKey;
|
||||
className?: string;
|
||||
}
|
||||
export default function FollowButton(props: FollowButtonProps) {
|
||||
const pubkey = parseId(props.pubkey);
|
||||
const publiser = useEventPublisher();
|
||||
const isFollowing = useSelector<RootState, boolean>(s => s.login.follows?.includes(pubkey) ?? false);
|
||||
const baseClassname = `${props.className} follow-button`;
|
||||
|
||||
async function follow(pubkey: HexKey) {
|
||||
const ev = await publiser.addFollow(pubkey);
|
||||
publiser.broadcast(ev);
|
||||
}
|
||||
|
||||
async function unfollow(pubkey: HexKey) {
|
||||
const ev = await publiser.removeFollow(pubkey);
|
||||
publiser.broadcast(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={isFollowing ? `${baseClassname} secondary` : baseClassname}
|
||||
onClick={() => (isFollowing ? unfollow(pubkey) : follow(pubkey))}>
|
||||
{isFollowing ? <FormattedMessage {...messages.Unfollow} /> : <FormattedMessage {...messages.Follow} />}
|
||||
</button>
|
||||
);
|
||||
}
|
35
packages/app/src/Element/FollowListBase.tsx
Normal file
35
packages/app/src/Element/FollowListBase.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { ReactNode } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface FollowListBaseProps {
|
||||
pubkeys: HexKey[];
|
||||
title?: ReactNode | string;
|
||||
}
|
||||
export default function FollowListBase({ pubkeys, title }: FollowListBaseProps) {
|
||||
const publisher = useEventPublisher();
|
||||
|
||||
async function followAll() {
|
||||
const ev = await publisher.addFollow(pubkeys);
|
||||
publisher.broadcast(ev);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="flex mt10 mb10">
|
||||
<div className="f-grow bold">{title}</div>
|
||||
<button className="transparent" type="button" onClick={() => followAll()}>
|
||||
<FormattedMessage {...messages.FollowAll} />
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => (
|
||||
<ProfilePreview pubkey={a} key={a} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
27
packages/app/src/Element/FollowersList.tsx
Normal file
27
packages/app/src/Element/FollowersList.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useMemo } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import useFollowersFeed from "Feed/FollowersFeed";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { EventKind } from "@snort/nostr";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface FollowersListProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
export default function FollowersList({ pubkey }: FollowersListProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const feed = useFollowersFeed(pubkey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
const contactLists = feed?.store.notes.filter(
|
||||
a => a.kind === EventKind.ContactList && a.tags.some(b => b[0] === "p" && b[1] === pubkey)
|
||||
);
|
||||
return [...new Set(contactLists?.map(a => a.pubkey))];
|
||||
}, [feed, pubkey]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowerCount, { n: pubkeys?.length })} />;
|
||||
}
|
24
packages/app/src/Element/FollowsList.tsx
Normal file
24
packages/app/src/Element/FollowsList.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useMemo } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import FollowListBase from "Element/FollowListBase";
|
||||
import { getFollowers } from "Feed/FollowsFeed";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface FollowsListProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
export default function FollowsList({ pubkey }: FollowsListProps) {
|
||||
const feed = useFollowsFeed(pubkey);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
|
||||
return <FollowListBase pubkeys={pubkeys} title={formatMessage(messages.FollowingCount, { n: pubkeys?.length })} />;
|
||||
}
|
6
packages/app/src/Element/FollowsYou.css
Normal file
6
packages/app/src/Element/FollowsYou.css
Normal file
@ -0,0 +1,6 @@
|
||||
.follows-you {
|
||||
color: var(--font-secondary-color);
|
||||
font-size: var(--font-size-tiny);
|
||||
margin-left: 0.2em;
|
||||
font-weight: normal;
|
||||
}
|
29
packages/app/src/Element/FollowsYou.tsx
Normal file
29
packages/app/src/Element/FollowsYou.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import "./FollowsYou.css";
|
||||
import { useMemo } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import useFollowsFeed from "Feed/FollowsFeed";
|
||||
import { getFollowers } from "Feed/FollowsFeed";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface FollowsYouProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
export default function FollowsYou({ pubkey }: FollowsYouProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const feed = useFollowsFeed(pubkey);
|
||||
const loginPubKey = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
|
||||
const pubkeys = useMemo(() => {
|
||||
return getFollowers(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
|
||||
const followsMe = loginPubKey ? pubkeys.includes(loginPubKey) : false;
|
||||
|
||||
return followsMe ? <span className="follows-you">{formatMessage(messages.FollowsYou)}</span> : null;
|
||||
}
|
3
packages/app/src/Element/Hashtag.css
Normal file
3
packages/app/src/Element/Hashtag.css
Normal file
@ -0,0 +1,3 @@
|
||||
.hashtag {
|
||||
color: var(--highlight);
|
||||
}
|
14
packages/app/src/Element/Hashtag.tsx
Normal file
14
packages/app/src/Element/Hashtag.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import "./Hashtag.css";
|
||||
|
||||
const Hashtag = ({ tag }: { tag: string }) => {
|
||||
return (
|
||||
<span className="hashtag">
|
||||
<Link to={`/t/${tag}`} onClick={e => e.stopPropagation()}>
|
||||
#{tag}
|
||||
</Link>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hashtag;
|
140
packages/app/src/Element/HyperText.tsx
Normal file
140
packages/app/src/Element/HyperText.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { TwitterTweetEmbed } from "react-twitter-embed";
|
||||
|
||||
import {
|
||||
FileExtensionRegex,
|
||||
YoutubeUrlRegex,
|
||||
TweetUrlRegex,
|
||||
TidalRegex,
|
||||
SoundCloudRegex,
|
||||
MixCloudRegex,
|
||||
SpotifyRegex,
|
||||
TwitchRegex,
|
||||
AppleMusicRegex,
|
||||
} from "Const";
|
||||
import { RootState } from "State/Store";
|
||||
import SoundCloudEmbed from "Element/SoundCloudEmded";
|
||||
import MixCloudEmbed from "Element/MixCloudEmbed";
|
||||
import SpotifyEmbed from "Element/SpotifyEmbed";
|
||||
import TidalEmbed from "Element/TidalEmbed";
|
||||
import { ProxyImg } from "Element/ProxyImg";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import TwitchEmbed from "./TwitchEmbed";
|
||||
import AppleMusicEmbed from "./AppleMusicEmbed";
|
||||
|
||||
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) {
|
||||
const pref = useSelector((s: RootState) => s.login.preferences);
|
||||
const follows = useSelector((s: RootState) => s.login.follows);
|
||||
const publicKey = useSelector((s: RootState) => s.login.publicKey);
|
||||
|
||||
const render = useCallback(() => {
|
||||
const a = link;
|
||||
try {
|
||||
const hideNonFollows = pref.autoLoadMedia === "follows-only" && !follows.includes(creator);
|
||||
const isMine = creator === publicKey;
|
||||
if (pref.autoLoadMedia === "none" || (!isMine && hideNonFollows)) {
|
||||
return (
|
||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
{a}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const url = new URL(a);
|
||||
const youtubeId = YoutubeUrlRegex.test(a) && RegExp.$1;
|
||||
const tweetId = TweetUrlRegex.test(a) && RegExp.$2;
|
||||
const tidalId = TidalRegex.test(a) && RegExp.$1;
|
||||
const soundcloundId = SoundCloudRegex.test(a) && RegExp.$1;
|
||||
const mixcloudId = MixCloudRegex.test(a) && RegExp.$1;
|
||||
const isSpotifyLink = SpotifyRegex.test(a);
|
||||
const isTwitchLink = TwitchRegex.test(a);
|
||||
const isAppleMusicLink = AppleMusicRegex.test(a);
|
||||
const extension = FileExtensionRegex.test(url.pathname.toLowerCase()) && RegExp.$1;
|
||||
if (extension && !isAppleMusicLink) {
|
||||
switch (extension) {
|
||||
case "gif":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "bmp":
|
||||
case "webp": {
|
||||
return <ProxyImg key={url.toString()} src={url.toString()} />;
|
||||
}
|
||||
case "wav":
|
||||
case "mp3":
|
||||
case "ogg": {
|
||||
return <audio key={url.toString()} src={url.toString()} controls />;
|
||||
}
|
||||
case "mp4":
|
||||
case "mov":
|
||||
case "mkv":
|
||||
case "avi":
|
||||
case "m4v": {
|
||||
return <video key={url.toString()} src={url.toString()} controls />;
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<a
|
||||
key={url.toString()}
|
||||
href={url.toString()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ext">
|
||||
{url.toString()}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
} else if (tweetId) {
|
||||
return (
|
||||
<div className="tweet" key={tweetId}>
|
||||
<TwitterTweetEmbed tweetId={tweetId} />
|
||||
</div>
|
||||
);
|
||||
} else if (youtubeId) {
|
||||
return (
|
||||
<>
|
||||
<br />
|
||||
<iframe
|
||||
className="w-max"
|
||||
src={`https://www.youtube.com/embed/${youtubeId}`}
|
||||
title="YouTube video player"
|
||||
key={youtubeId}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen={true}
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
);
|
||||
} else if (tidalId) {
|
||||
return <TidalEmbed link={a} />;
|
||||
} else if (soundcloundId) {
|
||||
return <SoundCloudEmbed link={a} />;
|
||||
} else if (mixcloudId) {
|
||||
return <MixCloudEmbed link={a} />;
|
||||
} else if (isSpotifyLink) {
|
||||
return <SpotifyEmbed link={a} />;
|
||||
} else if (isTwitchLink) {
|
||||
return <TwitchEmbed link={a} />;
|
||||
} else if (isAppleMusicLink) {
|
||||
return <AppleMusicEmbed link={a} />;
|
||||
} else {
|
||||
return (
|
||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
{a}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore the error.
|
||||
}
|
||||
return (
|
||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
{a}
|
||||
</a>
|
||||
);
|
||||
}, [link]);
|
||||
|
||||
return render();
|
||||
}
|
16
packages/app/src/Element/IconButton.tsx
Normal file
16
packages/app/src/Element/IconButton.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface IconButtonProps {
|
||||
onClick(): void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const IconButton = ({ onClick, children }: IconButtonProps) => {
|
||||
return (
|
||||
<button className="icon" type="button" onClick={onClick}>
|
||||
<div className="icon-wrapper">{children}</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
88
packages/app/src/Element/Invoice.css
Normal file
88
packages/app/src/Element/Invoice.css
Normal file
@ -0,0 +1,88 @@
|
||||
.note-invoice {
|
||||
border: 1px solid var(--gray-superdark);
|
||||
border-radius: 16px;
|
||||
padding: 26px 26px 20px 26px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
background: var(--invoice-gradient);
|
||||
}
|
||||
|
||||
.note-invoice.expired {
|
||||
background: var(--expired-invoice-gradient);
|
||||
color: var(--font-secondary-color);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.note-invoice.paid {
|
||||
background: var(--paid-invoice-gradient);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.invoice-header h4 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-amount {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-body {
|
||||
color: var(--font-secondary-color);
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-body p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-body button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.note-invoice.expired .invoice-body button {
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.note-invoice .invoice-body .paid {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
line-height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-amount {
|
||||
font-weight: 400;
|
||||
font-size: 37px;
|
||||
line-height: 45px;
|
||||
}
|
||||
|
||||
.note-invoice .invoice-amount .sats {
|
||||
color: var(--font-secondary-color);
|
||||
text-transform: uppercase;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.zap-circle {
|
||||
position: absolute;
|
||||
top: 26px;
|
||||
right: 20px;
|
||||
}
|
114
packages/app/src/Element/Invoice.tsx
Normal file
114
packages/app/src/Element/Invoice.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import "./Invoice.css";
|
||||
import { useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||
import { useMemo } from "react";
|
||||
import SendSats from "Element/SendSats";
|
||||
import ZapCircle from "Icons/ZapCircle";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface InvoiceProps {
|
||||
invoice: string;
|
||||
}
|
||||
|
||||
export default function Invoice(props: InvoiceProps) {
|
||||
const invoice = props.invoice;
|
||||
const webln = useWebln();
|
||||
const [showInvoice, setShowInvoice] = useState(false);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const info = useMemo(() => {
|
||||
try {
|
||||
const parsed = invoiceDecode(invoice);
|
||||
|
||||
const amountSection = parsed.sections.find(a => a.name === "amount");
|
||||
const amount = amountSection ? (amountSection.value as number) : NaN;
|
||||
|
||||
const timestampSection = parsed.sections.find(a => a.name === "timestamp");
|
||||
const timestamp = timestampSection ? (timestampSection.value as number) : NaN;
|
||||
|
||||
const expirySection = parsed.sections.find(a => a.name === "expiry");
|
||||
const expire = expirySection ? (expirySection.value as number) : NaN;
|
||||
const descriptionSection = parsed.sections.find(a => a.name === "description")?.value;
|
||||
const ret = {
|
||||
amount: !isNaN(amount) ? amount / 1000 : 0,
|
||||
expire: !isNaN(timestamp) && !isNaN(expire) ? timestamp + expire : null,
|
||||
description: descriptionSection as string | undefined,
|
||||
expired: false,
|
||||
};
|
||||
if (ret.expire) {
|
||||
ret.expired = ret.expire < new Date().getTime() / 1000;
|
||||
}
|
||||
return ret;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [invoice]);
|
||||
|
||||
const [isPaid, setIsPaid] = useState(false);
|
||||
const isExpired = info?.expired;
|
||||
const amount = info?.amount ?? 0;
|
||||
const description = info?.description;
|
||||
|
||||
function header() {
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.Invoice} />
|
||||
</h4>
|
||||
<ZapCircle className="zap-circle" />
|
||||
<SendSats
|
||||
title={formatMessage(messages.PayInvoice)}
|
||||
invoice={invoice}
|
||||
show={showInvoice}
|
||||
onClose={() => setShowInvoice(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function payInvoice(e: React.MouseEvent<HTMLButtonElement>) {
|
||||
e.stopPropagation();
|
||||
if (webln?.enabled) {
|
||||
try {
|
||||
await webln.sendPayment(invoice);
|
||||
setIsPaid(true);
|
||||
} catch (error) {
|
||||
setShowInvoice(true);
|
||||
}
|
||||
} else {
|
||||
setShowInvoice(true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`note-invoice flex ${isExpired ? "expired" : ""} ${isPaid ? "paid" : ""}`}>
|
||||
<div className="invoice-header">{header()}</div>
|
||||
|
||||
<p className="invoice-amount">
|
||||
{amount > 0 && (
|
||||
<>
|
||||
{amount.toLocaleString()} <span className="sats">sat{amount === 1 ? "" : "s"}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="invoice-body">
|
||||
{description && <p>{description}</p>}
|
||||
{isPaid ? (
|
||||
<div className="paid">
|
||||
<FormattedMessage {...messages.Paid} />
|
||||
</div>
|
||||
) : (
|
||||
<button disabled={isExpired} type="button" onClick={payInvoice}>
|
||||
{isExpired ? <FormattedMessage {...messages.Expired} /> : <FormattedMessage {...messages.Pay} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
37
packages/app/src/Element/LoadMore.tsx
Normal file
37
packages/app/src/Element/LoadMore.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export default function LoadMore({
|
||||
onLoadMore,
|
||||
shouldLoadMore,
|
||||
children,
|
||||
}: {
|
||||
onLoadMore: () => void;
|
||||
shouldLoadMore: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { ref, inView } = useInView();
|
||||
const [tick, setTick] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (inView === true && shouldLoadMore === true) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [inView, shouldLoadMore, tick]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setTick(x => (x += 1));
|
||||
}, 500);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="mb10">
|
||||
{children ?? <FormattedMessage {...messages.Loading} />}
|
||||
</div>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/LogoutButton.tsx
Normal file
23
packages/app/src/Element/LogoutButton.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import { logout } from "State/Login";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
dispatch(logout());
|
||||
navigate("/");
|
||||
}}>
|
||||
<FormattedMessage {...messages.Logout} />
|
||||
</button>
|
||||
);
|
||||
}
|
25
packages/app/src/Element/Mention.tsx
Normal file
25
packages/app/src/Element/Mention.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { hexToBech32, profileLink } from "Util";
|
||||
|
||||
export default function Mention({ pubkey }: { pubkey: HexKey }) {
|
||||
const user = useUserProfile(pubkey);
|
||||
|
||||
const name = useMemo(() => {
|
||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||
if (user?.display_name !== undefined && user.display_name.length > 0) {
|
||||
name = user.display_name;
|
||||
} else if (user?.name !== undefined && user.name.length > 0) {
|
||||
name = user.name;
|
||||
}
|
||||
return name;
|
||||
}, [user, pubkey]);
|
||||
|
||||
return (
|
||||
<Link to={profileLink(pubkey)} onClick={e => e.stopPropagation()}>
|
||||
@{name}
|
||||
</Link>
|
||||
);
|
||||
}
|
26
packages/app/src/Element/MixCloudEmbed.tsx
Normal file
26
packages/app/src/Element/MixCloudEmbed.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { MixCloudRegex } from "Const";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
const MixCloudEmbed = ({ link }: { link: string }) => {
|
||||
const feedPath = (MixCloudRegex.test(link) && RegExp.$1) + "%2F" + (MixCloudRegex.test(link) && RegExp.$2);
|
||||
|
||||
const lightTheme = useSelector<RootState, boolean>(s => s.login.preferences.theme === "light");
|
||||
|
||||
const lightParams = lightTheme ? "light=1" : "light=0";
|
||||
|
||||
return (
|
||||
<>
|
||||
<br />
|
||||
<iframe
|
||||
title="SoundCloud player"
|
||||
width="100%"
|
||||
height="120"
|
||||
frameBorder="0"
|
||||
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&${lightParams}&feed=%2F${feedPath}%2F`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MixCloudEmbed;
|
27
packages/app/src/Element/Modal.css
Normal file
27
packages/app/src/Element/Modal.css
Normal file
@ -0,0 +1,27 @@
|
||||
.modal {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--modal-bg-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
background-color: var(--note-bg);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
width: 500px;
|
||||
min-height: 10vh;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.modal-body {
|
||||
width: 100vw;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
43
packages/app/src/Element/Modal.tsx
Normal file
43
packages/app/src/Element/Modal.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import "./Modal.css";
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as React from "react";
|
||||
|
||||
export interface ModalProps {
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function useOnClickOutside(ref: React.MutableRefObject<Element | null>, onClickOutside: () => void) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(ev: MouseEvent) {
|
||||
if (ref && ref.current && !ref.current.contains(ev.target as Node)) {
|
||||
onClickOutside();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
const ref = useRef(null);
|
||||
const onClose = props.onClose || (() => undefined);
|
||||
const className = props.className || "";
|
||||
useOnClickOutside(ref, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.add("scroll-lock");
|
||||
return () => document.body.classList.remove("scroll-lock");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`modal ${className}`}>
|
||||
<div ref={ref} className="modal-body">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
24
packages/app/src/Element/MuteButton.tsx
Normal file
24
packages/app/src/Element/MuteButton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface MuteButtonProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
const MuteButton = ({ pubkey }: MuteButtonProps) => {
|
||||
const { mute, unmute, isMuted } = useModeration();
|
||||
return isMuted(pubkey) ? (
|
||||
<button className="secondary" type="button" onClick={() => unmute(pubkey)}>
|
||||
<FormattedMessage {...messages.Unmute} />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => mute(pubkey)}>
|
||||
<FormattedMessage {...messages.Mute} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MuteButton;
|
42
packages/app/src/Element/MutedList.tsx
Normal file
42
packages/app/src/Element/MutedList.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useMemo } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import MuteButton from "Element/MuteButton";
|
||||
import ProfilePreview from "Element/ProfilePreview";
|
||||
import useMutedFeed, { getMuted } from "Feed/MuteList";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface MutedListProps {
|
||||
pubkey: HexKey;
|
||||
}
|
||||
|
||||
export default function MutedList({ pubkey }: MutedListProps) {
|
||||
const { isMuted, muteAll } = useModeration();
|
||||
const feed = useMutedFeed(pubkey);
|
||||
const pubkeys = useMemo(() => {
|
||||
return getMuted(feed.store, pubkey);
|
||||
}, [feed, pubkey]);
|
||||
const hasAllMuted = pubkeys.every(isMuted);
|
||||
|
||||
return (
|
||||
<div className="main-content">
|
||||
<div className="flex mt10">
|
||||
<div className="f-grow bold">
|
||||
<FormattedMessage {...messages.MuteCount} values={{ n: pubkeys?.length }} />
|
||||
</div>
|
||||
<button
|
||||
disabled={hasAllMuted || pubkeys.length === 0}
|
||||
className="transparent"
|
||||
type="button"
|
||||
onClick={() => muteAll(pubkeys)}>
|
||||
<FormattedMessage {...messages.MuteAll} />
|
||||
</button>
|
||||
</div>
|
||||
{pubkeys?.map(a => {
|
||||
return <ProfilePreview actions={<MuteButton pubkey={a} />} pubkey={a} options={{ about: false }} key={a} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
47
packages/app/src/Element/Nip05.css
Normal file
47
packages/app/src/Element/Nip05.css
Normal file
@ -0,0 +1,47 @@
|
||||
.nip05 {
|
||||
color: var(--font-secondary-color);
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.nip05 .domain {
|
||||
color: var(--font-secondary-color);
|
||||
background-color: var(--font-secondary-color);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="snort.social"] {
|
||||
background-image: var(--snort-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="strike.army"] {
|
||||
background-image: var(--strike-army-gradient);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrplebs.com"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostrpurple.com"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostr.fan"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .domain[data-domain="nostriches.net"] {
|
||||
color: var(--highlight);
|
||||
background-color: var(--highlight);
|
||||
}
|
||||
|
||||
.nip05 .badge {
|
||||
margin: 0.1em 0.2em;
|
||||
}
|
75
packages/app/src/Element/Nip05.tsx
Normal file
75
packages/app/src/Element/Nip05.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircleCheck, faSpinner, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
import "./Nip05.css";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
|
||||
interface NostrJson {
|
||||
names: Record<string, string>;
|
||||
}
|
||||
|
||||
async function fetchNip05Pubkey(name: string, domain: string) {
|
||||
if (!name || !domain) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`);
|
||||
const data: NostrJson = await res.json();
|
||||
const match = Object.keys(data.names).find(n => {
|
||||
return n.toLowerCase() === name.toLowerCase();
|
||||
});
|
||||
return match ? data.names[match] : undefined;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const VERIFICATION_CACHE_TIME = 24 * 60 * 60 * 1000;
|
||||
const VERIFICATION_STALE_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
export function useIsVerified(pubkey: HexKey, nip05?: string, bypassCheck?: boolean) {
|
||||
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||
const { isError, isSuccess, data } = useQuery(
|
||||
["nip05", nip05],
|
||||
() => (bypassCheck ? Promise.resolve(pubkey) : fetchNip05Pubkey(name, domain)),
|
||||
{
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
cacheTime: VERIFICATION_CACHE_TIME,
|
||||
staleTime: VERIFICATION_STALE_TIMEOUT,
|
||||
}
|
||||
);
|
||||
const isVerified = isSuccess && data === pubkey;
|
||||
const cantVerify = isSuccess && data !== pubkey;
|
||||
return { isVerified, couldNotVerify: isError || cantVerify };
|
||||
}
|
||||
|
||||
export interface Nip05Params {
|
||||
nip05?: string;
|
||||
pubkey: HexKey;
|
||||
verifyNip?: boolean;
|
||||
}
|
||||
|
||||
const Nip05 = ({ nip05, pubkey, verifyNip = true }: Nip05Params) => {
|
||||
const [name, domain] = nip05 ? nip05.split("@") : [];
|
||||
const isDefaultUser = name === "_";
|
||||
const { isVerified, couldNotVerify } = useIsVerified(pubkey, nip05, !verifyNip);
|
||||
|
||||
return (
|
||||
<div className={`flex nip05${couldNotVerify ? " failed" : ""}`} onClick={ev => ev.stopPropagation()}>
|
||||
{!isDefaultUser && <div className="nick">{`${name}@`}</div>}
|
||||
<span className="domain" data-domain={domain?.toLowerCase()}>
|
||||
{domain}
|
||||
</span>
|
||||
<span className="badge">
|
||||
{isVerified && <FontAwesomeIcon color={"var(--highlight)"} icon={faCircleCheck} size="xs" />}
|
||||
{!isVerified && !couldNotVerify && <FontAwesomeIcon color={"var(--fg-color)"} icon={faSpinner} size="xs" />}
|
||||
{couldNotVerify && <FontAwesomeIcon color={"var(--error)"} icon={faTriangleExclamation} size="xs" />}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Nip05;
|
304
packages/app/src/Element/Nip5Service.tsx
Normal file
304
packages/app/src/Element/Nip5Service.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import { useEffect, useMemo, useState, ChangeEvent } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { unwrap } from "Util";
|
||||
import { formatShort } from "Number";
|
||||
import {
|
||||
ServiceProvider,
|
||||
ServiceConfig,
|
||||
ServiceError,
|
||||
HandleAvailability,
|
||||
ServiceErrorCode,
|
||||
HandleRegisterResponse,
|
||||
CheckRegisterResponse,
|
||||
} from "Nip05/ServiceProvider";
|
||||
import AsyncButton from "Element/AsyncButton";
|
||||
import SendSats from "Element/SendSats";
|
||||
import Copy from "Element/Copy";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { debounce } from "Util";
|
||||
import { UserMetadata } from "@snort/nostr";
|
||||
|
||||
import messages from "./messages";
|
||||
import { RootState } from "State/Store";
|
||||
|
||||
type Nip05ServiceProps = {
|
||||
name: string;
|
||||
service: URL | string;
|
||||
about: JSX.Element;
|
||||
link: string;
|
||||
supportLink: string;
|
||||
helpText?: boolean;
|
||||
onChange?(h: string): void;
|
||||
onSuccess?(h: string): void;
|
||||
};
|
||||
|
||||
export default function Nip5Service(props: Nip05ServiceProps) {
|
||||
const navigate = useNavigate();
|
||||
const { helpText = true } = props;
|
||||
const { formatMessage } = useIntl();
|
||||
const pubkey = useSelector((s: RootState) => s.login.publicKey);
|
||||
const user = useUserProfile(pubkey);
|
||||
const publisher = useEventPublisher();
|
||||
const svc = useMemo(() => new ServiceProvider(props.service), [props.service]);
|
||||
const [serviceConfig, setServiceConfig] = useState<ServiceConfig>();
|
||||
const [error, setError] = useState<ServiceError>();
|
||||
const [handle, setHandle] = useState<string>("");
|
||||
const [domain, setDomain] = useState<string>("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [availabilityResponse, setAvailabilityResponse] = useState<HandleAvailability>();
|
||||
const [registerResponse, setRegisterResponse] = useState<HandleRegisterResponse>();
|
||||
const [showInvoice, setShowInvoice] = useState<boolean>(false);
|
||||
const [registerStatus, setRegisterStatus] = useState<CheckRegisterResponse>();
|
||||
|
||||
const onHandleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const h = e.target.value.toLowerCase();
|
||||
setHandle(h);
|
||||
if (props.onChange) {
|
||||
props.onChange(`${h}@${domain}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onDomainChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const d = e.target.value;
|
||||
setDomain(d);
|
||||
if (props.onChange) {
|
||||
props.onChange(`${handle}@${d}`);
|
||||
}
|
||||
};
|
||||
|
||||
const domainConfig = useMemo(() => serviceConfig?.domains.find(a => a.name === domain), [domain, serviceConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
svc
|
||||
.GetConfig()
|
||||
.then(a => {
|
||||
if ("error" in a) {
|
||||
setError(a as ServiceError);
|
||||
} else {
|
||||
const svc = a as ServiceConfig;
|
||||
setServiceConfig(svc);
|
||||
const defaultDomain = svc.domains.find(a => a.default)?.name || svc.domains[0].name;
|
||||
setDomain(defaultDomain);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [props, svc]);
|
||||
|
||||
useEffect(() => {
|
||||
setError(undefined);
|
||||
setAvailabilityResponse(undefined);
|
||||
if (handle && domain) {
|
||||
if (handle.length < (domainConfig?.length[0] ?? 2)) {
|
||||
setAvailabilityResponse({ available: false, why: "TOO_SHORT" });
|
||||
return;
|
||||
}
|
||||
if (handle.length > (domainConfig?.length[1] ?? 20)) {
|
||||
setAvailabilityResponse({ available: false, why: "TOO_LONG" });
|
||||
return;
|
||||
}
|
||||
const rx = new RegExp(domainConfig?.regex[0] ?? "", domainConfig?.regex[1] ?? "");
|
||||
if (!rx.test(handle)) {
|
||||
setAvailabilityResponse({ available: false, why: "REGEX" });
|
||||
return;
|
||||
}
|
||||
return debounce(500, () => {
|
||||
svc
|
||||
.CheckAvailable(handle, domain)
|
||||
.then(a => {
|
||||
if ("error" in a) {
|
||||
setError(a as ServiceError);
|
||||
} else {
|
||||
setAvailabilityResponse(a as HandleAvailability);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
});
|
||||
}
|
||||
}, [handle, domain, domainConfig, svc]);
|
||||
|
||||
async function checkRegistration(rsp: HandleRegisterResponse) {
|
||||
const status = await svc.CheckRegistration(rsp.token);
|
||||
if ("error" in status) {
|
||||
setError(status);
|
||||
setRegisterResponse(undefined);
|
||||
setShowInvoice(false);
|
||||
} else {
|
||||
const result: CheckRegisterResponse = status;
|
||||
if (result.paid) {
|
||||
if (!result.available) {
|
||||
setError({
|
||||
error: "REGISTERED",
|
||||
} as ServiceError);
|
||||
} else {
|
||||
setError(undefined);
|
||||
}
|
||||
setShowInvoice(false);
|
||||
setRegisterStatus(status);
|
||||
setRegisterResponse(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (registerResponse && showInvoice && !checking) {
|
||||
const t = setInterval(() => {
|
||||
if (!checking) {
|
||||
setChecking(true);
|
||||
checkRegistration(registerResponse)
|
||||
.then(() => setChecking(false))
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
setChecking(false);
|
||||
});
|
||||
}
|
||||
}, 2_000);
|
||||
return () => clearInterval(t);
|
||||
}
|
||||
}, [registerResponse, showInvoice, svc, checking]);
|
||||
|
||||
function mapError(e: ServiceErrorCode | undefined, t: string | null): string | undefined {
|
||||
if (e === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const whyMap = new Map([
|
||||
["TOO_SHORT", formatMessage(messages.TooShort)],
|
||||
["TOO_LONG", formatMessage(messages.TooLong)],
|
||||
["REGEX", formatMessage(messages.Regex)],
|
||||
["REGISTERED", formatMessage(messages.Registered)],
|
||||
["DISALLOWED_null", formatMessage(messages.Disallowed)],
|
||||
["DISALLOWED_later", formatMessage(messages.DisalledLater)],
|
||||
]);
|
||||
return whyMap.get(e === "DISALLOWED" ? `${e}_${t}` : e);
|
||||
}
|
||||
|
||||
async function startBuy(handle: string, domain: string) {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rsp = await svc.RegisterHandle(handle, domain, pubkey);
|
||||
if ("error" in rsp) {
|
||||
setError(rsp);
|
||||
} else {
|
||||
setRegisterResponse(rsp);
|
||||
setShowInvoice(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(handle: string, domain: string) {
|
||||
if (user) {
|
||||
const nip05 = `${handle}@${domain}`;
|
||||
const newProfile = {
|
||||
...user,
|
||||
nip05,
|
||||
} as UserMetadata;
|
||||
const ev = await publisher.metadata(newProfile);
|
||||
publisher.broadcast(ev);
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(nip05);
|
||||
}
|
||||
if (helpText) {
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{helpText && <h3>{props.name}</h3>}
|
||||
{helpText && props.about}
|
||||
{helpText && (
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.FindMore}
|
||||
values={{
|
||||
service: props.name,
|
||||
link: (
|
||||
<a href={props.link} target="_blank" rel="noreferrer">
|
||||
{props.link}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{error && <b className="error">{error.error}</b>}
|
||||
{!registerStatus && (
|
||||
<div className="flex mb10">
|
||||
<input type="text" placeholder={formatMessage(messages.Handle)} value={handle} onChange={onHandleChange} />
|
||||
@
|
||||
<select value={domain} onChange={onDomainChange}>
|
||||
{serviceConfig?.domains.map(a => (
|
||||
<option key={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{availabilityResponse?.available && !registerStatus && (
|
||||
<div className="flex">
|
||||
<div className="mr10">
|
||||
<FormattedMessage
|
||||
{...messages.Sats}
|
||||
values={{ n: formatShort(unwrap(availabilityResponse.quote?.price)) }}
|
||||
/>
|
||||
<br />
|
||||
<small>{availabilityResponse.quote?.data.type}</small>
|
||||
</div>
|
||||
<AsyncButton onClick={() => startBuy(handle, domain)}>
|
||||
<FormattedMessage {...messages.BuyNow} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
{availabilityResponse?.available === false && !registerStatus && (
|
||||
<div className="flex">
|
||||
<b className="error">
|
||||
<FormattedMessage {...messages.NotAvailable} />{" "}
|
||||
{mapError(availabilityResponse.why, availabilityResponse.reasonTag || null)}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
<SendSats
|
||||
invoice={registerResponse?.invoice}
|
||||
show={showInvoice}
|
||||
onClose={() => setShowInvoice(false)}
|
||||
title={formatMessage(messages.Buying, { item: `${handle}@${domain}` })}
|
||||
/>
|
||||
{registerStatus?.paid && (
|
||||
<div className="flex f-col">
|
||||
<h4>
|
||||
<FormattedMessage {...messages.OrderPaid} />
|
||||
</h4>
|
||||
<p>
|
||||
<FormattedMessage {...messages.NewNip} />{" "}
|
||||
<code>
|
||||
{handle}@{domain}
|
||||
</code>
|
||||
</p>
|
||||
<h3>
|
||||
<FormattedMessage {...messages.AccountSupport} />
|
||||
</h3>
|
||||
<p>
|
||||
<FormattedMessage {...messages.SavePassword} />
|
||||
</p>
|
||||
<Copy text={registerStatus.password} />
|
||||
<p>
|
||||
<FormattedMessage {...messages.GoTo} />{" "}
|
||||
<a href={props.supportLink} target="_blank" rel="noreferrer">
|
||||
<FormattedMessage {...messages.AccountPage} />
|
||||
</a>
|
||||
</p>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.ActivateNow} />
|
||||
</h4>
|
||||
<AsyncButton onClick={() => updateProfile(handle, domain)}>
|
||||
<FormattedMessage {...messages.AddToProfile} />
|
||||
</AsyncButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
198
packages/app/src/Element/Note.css
Normal file
198
packages/app/src/Element/Note.css
Normal file
@ -0,0 +1,198 @@
|
||||
.note {
|
||||
min-height: 110px;
|
||||
}
|
||||
|
||||
.note > .header .reply {
|
||||
font-size: 13px;
|
||||
color: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.note > .header .reply a {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.note > .header .reply a:hover {
|
||||
text-decoration-color: var(--highlight);
|
||||
}
|
||||
|
||||
.note > .header > .info {
|
||||
font-size: var(--font-size);
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
color: var(--font-secondary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > .info .saved {
|
||||
margin-right: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
letter-spacing: 0.11em;
|
||||
text-transform: uppercase;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note > .header > .info .saved svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note > .header > .pinned {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--font-secondary-color);
|
||||
font-weight: 500;
|
||||
line-height: 22px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note > .header > .pinned svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.note > .body {
|
||||
margin-top: 4px;
|
||||
margin-bottom: 24px;
|
||||
padding-left: 56px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: pre-wrap;
|
||||
word-break: normal;
|
||||
overflow-x: hidden;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.note > .footer {
|
||||
padding-left: 46px;
|
||||
}
|
||||
|
||||
.note .footer .footer-reactions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.note .footer .footer-reactions {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.note > .footer .ctx-menu {
|
||||
color: var(--font-secondary-color);
|
||||
background: transparent;
|
||||
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.4);
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.light .note > .footer .ctx-menu {
|
||||
}
|
||||
|
||||
.note > .footer .ctx-menu li {
|
||||
background: #1e1e1e;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 2rem auto;
|
||||
}
|
||||
|
||||
.light .note > .footer .ctx-menu li {
|
||||
background: var(--note-bg);
|
||||
}
|
||||
|
||||
.note > .footer .ctx-menu li:first-of-type {
|
||||
padding-top: 12px;
|
||||
border-top-left-radius: 16px;
|
||||
border-top-right-radius: 16px;
|
||||
}
|
||||
.note > .footer .ctx-menu li:last-of-type {
|
||||
padding-bottom: 12px;
|
||||
border-bottom-left-radius: 16px;
|
||||
border-bottom-right-radius: 16px;
|
||||
}
|
||||
|
||||
.light .note > .footer .ctx-menu li:hover {
|
||||
color: white;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.note > .footer .ctx-menu li:hover {
|
||||
color: white;
|
||||
background: var(--font-secondary-color);
|
||||
}
|
||||
|
||||
.ctx-menu .red {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.note > .header img:hover,
|
||||
.note > .header .name > .reply:hover,
|
||||
.note .body:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note > .note-creator {
|
||||
margin-top: 12px;
|
||||
margin-left: 56px;
|
||||
}
|
||||
|
||||
.reaction-pill {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 0px 14px;
|
||||
user-select: none;
|
||||
color: var(--font-secondary-color);
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.reaction-pill .reaction-pill-number {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.reaction-pill.reacted {
|
||||
color: var(--highlight);
|
||||
}
|
||||
|
||||
.reaction-pill:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.trash-icon {
|
||||
color: var(--error);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.note-expand .body {
|
||||
max-height: 300px;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.hidden-note .header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card.note.hidden-note {
|
||||
min-height: unset;
|
||||
}
|
||||
|
||||
.hidden-note button {
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
.expand-note {
|
||||
padding: 0 0 16px 0;
|
||||
font-weight: 400;
|
||||
color: var(--highlight);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.note.active {
|
||||
border-left: 1px solid var(--highlight);
|
||||
border-bottom-left-radius: 0;
|
||||
margin-left: -1px;
|
||||
}
|
268
packages/app/src/Element/Note.tsx
Normal file
268
packages/app/src/Element/Note.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import "./Note.css";
|
||||
import React, { useCallback, useMemo, useState, useLayoutEffect, ReactNode } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import Bookmark from "Icons/Bookmark";
|
||||
import Pin from "Icons/Pin";
|
||||
import { Event as NEvent, EventKind } from "@snort/nostr";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Text from "Element/Text";
|
||||
import { eventLink, getReactions, hexToBech32 } from "Util";
|
||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { useUserProfiles } from "Feed/ProfileFeed";
|
||||
import { TaggedRawEvent, u256, HexKey } from "@snort/nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { setPinned, setBookmarked } from "State/Login";
|
||||
import type { RootState } from "State/Store";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface NoteProps {
|
||||
data?: TaggedRawEvent;
|
||||
className?: string;
|
||||
related: TaggedRawEvent[];
|
||||
highlight?: boolean;
|
||||
ignoreModeration?: boolean;
|
||||
options?: {
|
||||
showHeader?: boolean;
|
||||
showTime?: boolean;
|
||||
showPinned?: boolean;
|
||||
showBookmarked?: boolean;
|
||||
showFooter?: boolean;
|
||||
canUnpin?: boolean;
|
||||
canUnbookmark?: boolean;
|
||||
};
|
||||
["data-ev"]?: NEvent;
|
||||
}
|
||||
|
||||
const HiddenNote = ({ children }: { children: React.ReactNode }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
return show ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<div className="card note hidden-note">
|
||||
<div className="header">
|
||||
<p>
|
||||
<FormattedMessage {...messages.MutedAuthor} />
|
||||
</p>
|
||||
<button onClick={() => setShow(true)}>
|
||||
<FormattedMessage {...messages.Show} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Note(props: NoteProps) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { data, related, highlight, options: opt, ["data-ev"]: parsedEvent, ignoreModeration = false } = props;
|
||||
const ev = useMemo(() => parsedEvent ?? new NEvent(data), [data]);
|
||||
const pubKeys = useMemo(() => ev.Thread?.PubKeys || [], [ev]);
|
||||
const users = useUserProfiles(pubKeys);
|
||||
const deletions = useMemo(() => getReactions(related, ev.Id, EventKind.Deletion), [related]);
|
||||
const { isMuted } = useModeration();
|
||||
const isOpMuted = isMuted(ev.PubKey);
|
||||
const { ref, inView, entry } = useInView({ triggerOnce: true });
|
||||
const [extendable, setExtendable] = useState<boolean>(false);
|
||||
const [showMore, setShowMore] = useState<boolean>(false);
|
||||
const baseClassName = `note card ${props.className ? props.className : ""}`;
|
||||
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
|
||||
const publisher = useEventPublisher();
|
||||
const [translated, setTranslated] = useState<Translation>();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const options = {
|
||||
showHeader: true,
|
||||
showTime: true,
|
||||
showFooter: true,
|
||||
canUnpin: false,
|
||||
canUnbookmark: false,
|
||||
...opt,
|
||||
};
|
||||
|
||||
async function unpin(id: HexKey) {
|
||||
if (options.canUnpin) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnpin))) {
|
||||
const es = pinned.filter(e => e !== id);
|
||||
const ev = await publisher.pinned(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unbookmark(id: HexKey) {
|
||||
if (options.canUnbookmark) {
|
||||
if (window.confirm(formatMessage(messages.ConfirmUnbookmark))) {
|
||||
const es = bookmarked.filter(e => e !== id);
|
||||
const ev = await publisher.bookmarked(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const transformBody = useCallback(() => {
|
||||
const body = ev?.Content ?? "";
|
||||
if (deletions?.length > 0) {
|
||||
return (
|
||||
<b className="error">
|
||||
<FormattedMessage {...messages.Deleted} />
|
||||
</b>
|
||||
);
|
||||
}
|
||||
return <Text content={body} tags={ev.Tags} users={users || new Map()} creator={ev.PubKey} />;
|
||||
}, [ev]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (entry && inView && extendable === false) {
|
||||
const h = entry?.target.clientHeight ?? 0;
|
||||
if (h > 650) {
|
||||
setExtendable(true);
|
||||
}
|
||||
}
|
||||
}, [inView, entry, extendable]);
|
||||
|
||||
function goToEvent(e: React.MouseEvent, id: u256) {
|
||||
e.stopPropagation();
|
||||
navigate(eventLink(id));
|
||||
}
|
||||
|
||||
function replyTag() {
|
||||
if (ev.Thread === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxMentions = 2;
|
||||
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
||||
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
||||
for (const pk of ev.Thread?.PubKeys ?? []) {
|
||||
const u = users?.get(pk);
|
||||
const npub = hexToBech32("npub", pk);
|
||||
const shortNpub = npub.substring(0, 12);
|
||||
if (u) {
|
||||
mentions.push({
|
||||
pk,
|
||||
name: u.name ?? shortNpub,
|
||||
link: <Link to={`/p/${npub}`}>{u.name ? `@${u.name}` : shortNpub}</Link>,
|
||||
});
|
||||
} else {
|
||||
mentions.push({
|
||||
pk,
|
||||
name: shortNpub,
|
||||
link: <Link to={`/p/${npub}`}>{shortNpub}</Link>,
|
||||
});
|
||||
}
|
||||
}
|
||||
mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1));
|
||||
const othersLength = mentions.length - maxMentions;
|
||||
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
|
||||
return (
|
||||
<React.Fragment key={m.pk}>
|
||||
{idx > 0 && ", "}
|
||||
{m.link}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
const pubMentions =
|
||||
mentions.length > maxMentions ? mentions?.slice(0, maxMentions).map(renderMention) : mentions?.map(renderMention);
|
||||
const others = mentions.length > maxMentions ? formatMessage(messages.Others, { n: othersLength }) : "";
|
||||
return (
|
||||
<div className="reply">
|
||||
re:
|
||||
{(mentions?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
{pubMentions}
|
||||
{others}
|
||||
</>
|
||||
) : (
|
||||
replyId && <Link to={eventLink(replyId)}>{hexToBech32("note", replyId)?.substring(0, 12)}</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ev.Kind !== EventKind.TextNote) {
|
||||
return (
|
||||
<>
|
||||
<h4>
|
||||
<FormattedMessage {...messages.UnknownEventKind} values={{ kind: ev.Kind }} />
|
||||
</h4>
|
||||
<pre>{JSON.stringify(ev.ToObject(), undefined, " ")}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function translation() {
|
||||
if (translated && translated.confidence > 0.5) {
|
||||
return (
|
||||
<>
|
||||
<p className="highlight">
|
||||
<FormattedMessage {...messages.TranslatedFrom} values={{ lang: translated.fromLanguage }} />
|
||||
</p>
|
||||
{translated.text}
|
||||
</>
|
||||
);
|
||||
} else if (translated) {
|
||||
return (
|
||||
<p className="highlight">
|
||||
<FormattedMessage {...messages.TranslationFailed} />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function content() {
|
||||
if (!inView) return null;
|
||||
return (
|
||||
<>
|
||||
{options.showHeader && (
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} subHeader={replyTag() ?? undefined} />
|
||||
{(options.showTime || options.showBookmarked) && (
|
||||
<div className="info">
|
||||
{options.showBookmarked && (
|
||||
<div className={`saved ${options.canUnbookmark ? "pointer" : ""}`} onClick={() => unbookmark(ev.Id)}>
|
||||
<Bookmark /> <FormattedMessage {...messages.Bookmarked} />
|
||||
</div>
|
||||
)}
|
||||
{!options.showBookmarked && <NoteTime from={ev.CreatedAt * 1000} />}
|
||||
</div>
|
||||
)}
|
||||
{options.showPinned && (
|
||||
<div className={`pinned ${options.canUnpin ? "pointer" : ""}`} onClick={() => unpin(ev.Id)}>
|
||||
<Pin /> <FormattedMessage {...messages.Pinned} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="body" onClick={e => goToEvent(e, ev.Id)}>
|
||||
{transformBody()}
|
||||
{translation()}
|
||||
</div>
|
||||
{extendable && !showMore && (
|
||||
<span className="expand-note mt10 flex f-center" onClick={() => setShowMore(true)}>
|
||||
<FormattedMessage {...messages.ShowMore} />
|
||||
</span>
|
||||
)}
|
||||
{options.showFooter && <NoteFooter ev={ev} related={related} onTranslated={t => setTranslated(t)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const note = (
|
||||
<div
|
||||
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
||||
ref={ref}>
|
||||
{content()}
|
||||
</div>
|
||||
);
|
||||
|
||||
return !ignoreModeration && isOpMuted ? <HiddenNote>{note}</HiddenNote> : note;
|
||||
}
|
149
packages/app/src/Element/NoteCreator.css
Normal file
149
packages/app/src/Element/NoteCreator.css
Normal file
@ -0,0 +1,149 @@
|
||||
.note-creator {
|
||||
margin-bottom: 10px;
|
||||
background-color: var(--note-bg);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-reply {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.note-creator textarea {
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
background-color: var(--note-bg);
|
||||
border-radius: 10px 10px 0 0;
|
||||
min-height: 120px;
|
||||
max-width: stretch;
|
||||
min-width: stretch;
|
||||
}
|
||||
|
||||
.note-creator textarea::placeholder {
|
||||
color: var(--font-secondary-color);
|
||||
font-size: var(--font-size);
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.note-creator textarea {
|
||||
min-height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.note-creator textarea {
|
||||
min-height: 321px;
|
||||
}
|
||||
}
|
||||
|
||||
.note-creator-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.note-creator .attachment {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 12px;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
background: var(--gray-dark);
|
||||
color: white;
|
||||
border-radius: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.note-creator .attachment:hover {
|
||||
background: var(--font-color);
|
||||
color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.light .note-creator .attachment {
|
||||
background: var(--gray-light);
|
||||
}
|
||||
|
||||
.light .note-creator .attachment:hover {
|
||||
background: var(--gray-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.note-creator-actions button:not(:last-child) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.note-creator .error {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 12px;
|
||||
font-color: var(--error);
|
||||
margin-right: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.note-creator .btn {
|
||||
border-radius: 20px;
|
||||
font-weight: bold;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--font-color);
|
||||
font-size: var(--font-size);
|
||||
}
|
||||
|
||||
.note-create-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--highlight);
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
position: fixed;
|
||||
bottom: 50px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.note-create-button {
|
||||
right: 10vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1020px) {
|
||||
.note-create-button {
|
||||
right: calc(50% - 360px);
|
||||
}
|
||||
}
|
||||
|
||||
.note-creator-modal .modal-body {
|
||||
background: var(--modal-bg-color);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.note-creator-modal {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.note-creator-modal .modal-body {
|
||||
margin-top: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.note-preview-body {
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 4px 0 56px;
|
||||
font-size: 14px;
|
||||
}
|
133
packages/app/src/Element/NoteCreator.tsx
Normal file
133
packages/app/src/Element/NoteCreator.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import "./NoteCreator.css";
|
||||
import { useState } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
import Attachment from "Icons/Attachment";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { openFile } from "Util";
|
||||
import Textarea from "Element/Textarea";
|
||||
import Modal from "Element/Modal";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { Event as NEvent } from "@snort/nostr";
|
||||
import useFileUpload from "Upload";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface NotePreviewProps {
|
||||
note: NEvent;
|
||||
}
|
||||
|
||||
function NotePreview({ note }: NotePreviewProps) {
|
||||
return (
|
||||
<div className="note-preview">
|
||||
<ProfileImage pubkey={note.PubKey} />
|
||||
<div className="note-preview-body">
|
||||
{note.Content.slice(0, 136)}
|
||||
{note.Content.length > 140 && "..."}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface NoteCreatorProps {
|
||||
show: boolean;
|
||||
setShow: (s: boolean) => void;
|
||||
replyTo?: NEvent;
|
||||
onSend?: () => void;
|
||||
autoFocus: boolean;
|
||||
}
|
||||
|
||||
export function NoteCreator(props: NoteCreatorProps) {
|
||||
const { show, setShow, replyTo, onSend, autoFocus } = props;
|
||||
const publisher = useEventPublisher();
|
||||
const [note, setNote] = useState<string>("");
|
||||
const [error, setError] = useState<string>();
|
||||
const [active, setActive] = useState<boolean>(false);
|
||||
const uploader = useFileUpload();
|
||||
const hasErrors = (error?.length ?? 0) > 0;
|
||||
|
||||
async function sendNote() {
|
||||
if (note) {
|
||||
const ev = replyTo ? await publisher.reply(replyTo, note) : await publisher.note(note);
|
||||
console.debug("Sending note: ", ev);
|
||||
publisher.broadcast(ev);
|
||||
setNote("");
|
||||
setShow(false);
|
||||
if (typeof onSend === "function") {
|
||||
onSend();
|
||||
}
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function attachFile() {
|
||||
try {
|
||||
const file = await openFile();
|
||||
if (file) {
|
||||
const rx = await uploader.upload(file, file.name);
|
||||
if (rx.url) {
|
||||
setNote(n => `${n ? `${n}\n` : ""}${rx.url}`);
|
||||
} else if (rx?.error) {
|
||||
setError(rx.error);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
setError(error?.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(ev: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const { value } = ev.target;
|
||||
setNote(value);
|
||||
if (value) {
|
||||
setActive(true);
|
||||
} else {
|
||||
setActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setShow(false);
|
||||
setNote("");
|
||||
}
|
||||
|
||||
function onSubmit(ev: React.MouseEvent<HTMLButtonElement>) {
|
||||
ev.stopPropagation();
|
||||
sendNote().catch(console.warn);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{show && (
|
||||
<Modal className="note-creator-modal" onClose={() => setShow(false)}>
|
||||
{replyTo && <NotePreview note={replyTo} />}
|
||||
<div className={`flex note-creator ${replyTo ? "note-reply" : ""}`}>
|
||||
<div className="flex f-col mr10 f-grow">
|
||||
<Textarea
|
||||
autoFocus={autoFocus}
|
||||
className={`textarea ${active ? "textarea--focused" : ""}`}
|
||||
onChange={onChange}
|
||||
value={note}
|
||||
onFocus={() => setActive(true)}
|
||||
/>
|
||||
<button type="button" className="attachment" onClick={attachFile}>
|
||||
<Attachment />
|
||||
</button>
|
||||
</div>
|
||||
{hasErrors && <span className="error">{error}</span>}
|
||||
</div>
|
||||
<div className="note-creator-actions">
|
||||
<button className="secondary" type="button" onClick={cancel}>
|
||||
<FormattedMessage {...messages.Cancel} />
|
||||
</button>
|
||||
<button type="button" onClick={onSubmit}>
|
||||
{replyTo ? <FormattedMessage {...messages.Reply} /> : <FormattedMessage {...messages.Send} />}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
345
packages/app/src/Element/NoteFooter.tsx
Normal file
345
packages/app/src/Element/NoteFooter.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||
|
||||
import Bookmark from "Icons/Bookmark";
|
||||
import Pin from "Icons/Pin";
|
||||
import Json from "Icons/Json";
|
||||
import Repost from "Icons/Repost";
|
||||
import Trash from "Icons/Trash";
|
||||
import Translate from "Icons/Translate";
|
||||
import Block from "Icons/Block";
|
||||
import Mute from "Icons/Mute";
|
||||
import Share from "Icons/Share";
|
||||
import Copy from "Icons/Copy";
|
||||
import Dislike from "Icons/Dislike";
|
||||
import Heart from "Icons/Heart";
|
||||
import Dots from "Icons/Dots";
|
||||
import Zap from "Icons/Zap";
|
||||
import Reply from "Icons/Reply";
|
||||
import { formatShort } from "Number";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import { getReactions, dedupeByPubkey, hexToBech32, normalizeReaction, Reaction } from "Util";
|
||||
import { NoteCreator } from "Element/NoteCreator";
|
||||
import Reactions from "Element/Reactions";
|
||||
import SendSats from "Element/SendSats";
|
||||
import { parseZap, ZapsSummary } from "Element/Zap";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { Event as NEvent, EventKind, TaggedRawEvent } from "@snort/nostr";
|
||||
import { RootState } from "State/Store";
|
||||
import { UserPreferences, setPinned, setBookmarked } from "State/Login";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
import { TranslateHost } from "Const";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface Translation {
|
||||
text: string;
|
||||
fromLanguage: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface NoteFooterProps {
|
||||
related: TaggedRawEvent[];
|
||||
ev: NEvent;
|
||||
onTranslated?: (content: Translation) => void;
|
||||
}
|
||||
|
||||
export default function NoteFooter(props: NoteFooterProps) {
|
||||
const { related, ev } = props;
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const { pinned, bookmarked } = useSelector((s: RootState) => s.login);
|
||||
const login = useSelector<RootState, HexKey | undefined>(s => s.login.publicKey);
|
||||
const { mute, block } = useModeration();
|
||||
const prefs = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||
const author = useUserProfile(ev.RootPubKey);
|
||||
const publisher = useEventPublisher();
|
||||
const [reply, setReply] = useState(false);
|
||||
const [showReactions, setShowReactions] = useState(false);
|
||||
const [tip, setTip] = useState(false);
|
||||
const isMine = ev.RootPubKey === login;
|
||||
const lang = window.navigator.language;
|
||||
const langNames = new Intl.DisplayNames([...window.navigator.languages], {
|
||||
type: "language",
|
||||
});
|
||||
const reactions = useMemo(() => getReactions(related, ev.Id, EventKind.Reaction), [related, ev]);
|
||||
const reposts = useMemo(() => dedupeByPubkey(getReactions(related, ev.Id, EventKind.Repost)), [related, ev]);
|
||||
const zaps = useMemo(() => {
|
||||
const sortedZaps = getReactions(related, ev.Id, EventKind.ZapReceipt)
|
||||
.map(parseZap)
|
||||
.filter(z => z.valid && z.zapper !== ev.PubKey);
|
||||
sortedZaps.sort((a, b) => b.amount - a.amount);
|
||||
return sortedZaps;
|
||||
}, [related]);
|
||||
const zapTotal = zaps.reduce((acc, z) => acc + z.amount, 0);
|
||||
const didZap = zaps.some(a => a.zapper === login);
|
||||
const groupReactions = useMemo(() => {
|
||||
const result = reactions?.reduce(
|
||||
(acc, reaction) => {
|
||||
const kind = normalizeReaction(reaction.content);
|
||||
const rs = acc[kind] || [];
|
||||
if (rs.map(e => e.pubkey).includes(reaction.pubkey)) {
|
||||
return acc;
|
||||
}
|
||||
return { ...acc, [kind]: [...rs, reaction] };
|
||||
},
|
||||
{
|
||||
[Reaction.Positive]: [] as TaggedRawEvent[],
|
||||
[Reaction.Negative]: [] as TaggedRawEvent[],
|
||||
}
|
||||
);
|
||||
return {
|
||||
[Reaction.Positive]: dedupeByPubkey(result[Reaction.Positive]),
|
||||
[Reaction.Negative]: dedupeByPubkey(result[Reaction.Negative]),
|
||||
};
|
||||
}, [reactions]);
|
||||
const positive = groupReactions[Reaction.Positive];
|
||||
const negative = groupReactions[Reaction.Negative];
|
||||
|
||||
function hasReacted(emoji: string) {
|
||||
return reactions?.some(({ pubkey, content }) => normalizeReaction(content) === emoji && pubkey === login);
|
||||
}
|
||||
|
||||
function hasReposted() {
|
||||
return reposts.some(a => a.pubkey === login);
|
||||
}
|
||||
|
||||
async function react(content: string) {
|
||||
if (!hasReacted(content)) {
|
||||
const evLike = await publisher.react(ev, content);
|
||||
publisher.broadcast(evLike);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEvent() {
|
||||
if (window.confirm(formatMessage(messages.ConfirmDeletion, { id: ev.Id.substring(0, 8) }))) {
|
||||
const evDelete = await publisher.delete(ev.Id);
|
||||
publisher.broadcast(evDelete);
|
||||
}
|
||||
}
|
||||
|
||||
async function repost() {
|
||||
if (!hasReposted()) {
|
||||
if (!prefs.confirmReposts || window.confirm(formatMessage(messages.ConfirmRepost, { id: ev.Id }))) {
|
||||
const evRepost = await publisher.repost(ev);
|
||||
publisher.broadcast(evRepost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tipButton() {
|
||||
const service = author?.lud16 || author?.lud06;
|
||||
if (service) {
|
||||
return (
|
||||
<>
|
||||
<div className={`reaction-pill ${didZap ? "reacted" : ""}`} onClick={() => setTip(true)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<Zap />
|
||||
</div>
|
||||
{zapTotal > 0 && <div className="reaction-pill-number">{formatShort(zapTotal)}</div>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function repostIcon() {
|
||||
return (
|
||||
<div className={`reaction-pill ${hasReposted() ? "reacted" : ""}`} onClick={() => repost()}>
|
||||
<div className="reaction-pill-icon">
|
||||
<Repost width={18} height={16} />
|
||||
</div>
|
||||
{reposts.length > 0 && <div className="reaction-pill-number">{formatShort(reposts.length)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function reactionIcons() {
|
||||
if (!prefs.enableReactions) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`reaction-pill ${hasReacted("+") ? "reacted" : ""} `}
|
||||
onClick={() => react(prefs.reactionEmoji)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<Heart />
|
||||
</div>
|
||||
<div className="reaction-pill-number">{formatShort(positive.length)}</div>
|
||||
</div>
|
||||
{repostIcon()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`;
|
||||
if ("share" in window.navigator) {
|
||||
await window.navigator.share({
|
||||
title: "Snort",
|
||||
url: url,
|
||||
});
|
||||
} else {
|
||||
await navigator.clipboard.writeText(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function translate() {
|
||||
const res = await fetch(`${TranslateHost}/translate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: ev.Content,
|
||||
source: "auto",
|
||||
target: lang.split("-")[0],
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
if (typeof props.onTranslated === "function" && result) {
|
||||
props.onTranslated({
|
||||
text: result.translatedText,
|
||||
fromLanguage: langNames.of(result.detectedLanguage.language),
|
||||
confidence: result.detectedLanguage.confidence,
|
||||
} as Translation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(hexToBech32("note", ev.Id));
|
||||
}
|
||||
|
||||
async function pin(id: HexKey) {
|
||||
const es = [...pinned, id];
|
||||
const ev = await publisher.pinned(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setPinned({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
|
||||
async function bookmark(id: HexKey) {
|
||||
const es = [...bookmarked, id];
|
||||
const ev = await publisher.bookmarked(es);
|
||||
publisher.broadcast(ev);
|
||||
dispatch(setBookmarked({ keys: es, createdAt: new Date().getTime() }));
|
||||
}
|
||||
|
||||
async function copyEvent() {
|
||||
await navigator.clipboard.writeText(JSON.stringify(ev.Original, undefined, " "));
|
||||
}
|
||||
|
||||
function menuItems() {
|
||||
return (
|
||||
<>
|
||||
{prefs.enableReactions && (
|
||||
<MenuItem onClick={() => setShowReactions(true)}>
|
||||
<Heart />
|
||||
<FormattedMessage {...messages.Reactions} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => share()}>
|
||||
<Share />
|
||||
<FormattedMessage {...messages.Share} />
|
||||
</MenuItem>
|
||||
{!pinned.includes(ev.Id) && (
|
||||
<MenuItem onClick={() => pin(ev.Id)}>
|
||||
<Pin />
|
||||
<FormattedMessage {...messages.Pin} />
|
||||
</MenuItem>
|
||||
)}
|
||||
{!bookmarked.includes(ev.Id) && (
|
||||
<MenuItem onClick={() => bookmark(ev.Id)}>
|
||||
<Bookmark width={18} height={18} />
|
||||
<FormattedMessage {...messages.Bookmark} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => copyId()}>
|
||||
<Copy />
|
||||
<FormattedMessage {...messages.CopyID} />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => mute(ev.PubKey)}>
|
||||
<Mute />
|
||||
<FormattedMessage {...messages.Mute} />
|
||||
</MenuItem>
|
||||
{prefs.enableReactions && (
|
||||
<MenuItem onClick={() => react("-")}>
|
||||
<Dislike />
|
||||
<FormattedMessage {...messages.DislikeAction} />
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={() => block(ev.PubKey)}>
|
||||
<Block />
|
||||
<FormattedMessage {...messages.Block} />
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => translate()}>
|
||||
<Translate />
|
||||
<FormattedMessage {...messages.TranslateTo} values={{ lang: langNames.of(lang.split("-")[0]) }} />
|
||||
</MenuItem>
|
||||
{prefs.showDebugMenus && (
|
||||
<MenuItem onClick={() => copyEvent()}>
|
||||
<Json />
|
||||
<FormattedMessage {...messages.CopyJSON} />
|
||||
</MenuItem>
|
||||
)}
|
||||
{isMine && (
|
||||
<MenuItem onClick={() => deleteEvent()}>
|
||||
<Trash className="red" />
|
||||
<FormattedMessage {...messages.Delete} />
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="footer">
|
||||
<div className="footer-reactions">
|
||||
{tipButton()}
|
||||
{reactionIcons()}
|
||||
<div className={`reaction-pill ${reply ? "reacted" : ""}`} onClick={() => setReply(s => !s)}>
|
||||
<div className="reaction-pill-icon">
|
||||
<Reply />
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
menuButton={
|
||||
<div className="reaction-pill">
|
||||
<div className="reaction-pill-icon">
|
||||
<Dots />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
menuClassName="ctx-menu">
|
||||
{menuItems()}
|
||||
</Menu>
|
||||
</div>
|
||||
<NoteCreator autoFocus={true} replyTo={ev} onSend={() => setReply(false)} show={reply} setShow={setReply} />
|
||||
<Reactions
|
||||
show={showReactions}
|
||||
setShow={setShowReactions}
|
||||
positive={positive}
|
||||
negative={negative}
|
||||
reposts={reposts}
|
||||
zaps={zaps}
|
||||
/>
|
||||
<SendSats
|
||||
svc={author?.lud16 || author?.lud06}
|
||||
onClose={() => setTip(false)}
|
||||
show={tip}
|
||||
author={author?.pubkey}
|
||||
target={author?.display_name || author?.name}
|
||||
note={ev.Id}
|
||||
/>
|
||||
</div>
|
||||
<div className="zaps-container">
|
||||
<ZapsSummary zaps={zaps} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
20
packages/app/src/Element/NoteGhost.tsx
Normal file
20
packages/app/src/Element/NoteGhost.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import "./Note.css";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
|
||||
interface NoteGhostProps {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function NoteGhost(props: NoteGhostProps) {
|
||||
const className = `note card ${props.className ?? ""}`;
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="header">
|
||||
<ProfileImage pubkey="" />
|
||||
</div>
|
||||
<div className="body">{props.children}</div>
|
||||
<div className="footer"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
23
packages/app/src/Element/NoteReaction.css
Normal file
23
packages/app/src/Element/NoteReaction.css
Normal file
@ -0,0 +1,23 @@
|
||||
.reaction {
|
||||
}
|
||||
|
||||
.reaction > .note {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.reaction > .header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reaction > .header .reply {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.reaction > .header > .info {
|
||||
font-size: var(--font-size);
|
||||
white-space: nowrap;
|
||||
color: var(--font-secondary-color);
|
||||
margin-right: 24px;
|
||||
}
|
76
packages/app/src/Element/NoteReaction.tsx
Normal file
76
packages/app/src/Element/NoteReaction.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import "./NoteReaction.css";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { EventKind, Event as NEvent } from "@snort/nostr";
|
||||
import Note from "Element/Note";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import { eventLink, hexToBech32 } from "Util";
|
||||
import NoteTime from "Element/NoteTime";
|
||||
import { RawEvent, TaggedRawEvent } from "@snort/nostr";
|
||||
import useModeration from "Hooks/useModeration";
|
||||
|
||||
export interface NoteReactionProps {
|
||||
data?: TaggedRawEvent;
|
||||
["data-ev"]?: NEvent;
|
||||
root?: TaggedRawEvent;
|
||||
}
|
||||
export default function NoteReaction(props: NoteReactionProps) {
|
||||
const { ["data-ev"]: dataEv, data } = props;
|
||||
const ev = useMemo(() => dataEv || new NEvent(data), [data, dataEv]);
|
||||
const { isMuted } = useModeration();
|
||||
|
||||
const refEvent = useMemo(() => {
|
||||
if (ev) {
|
||||
const eTags = ev.Tags.filter(a => a.Key === "e");
|
||||
if (eTags.length > 0) {
|
||||
return eTags[0].Event;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [ev]);
|
||||
|
||||
if (ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some clients embed the reposted note in the content
|
||||
*/
|
||||
function extractRoot() {
|
||||
if (ev?.Kind === EventKind.Repost && ev.Content.length > 0 && ev.Content !== "#[0]") {
|
||||
try {
|
||||
const r: RawEvent = JSON.parse(ev.Content);
|
||||
return r as TaggedRawEvent;
|
||||
} catch (e) {
|
||||
console.error("Could not load reposted content", e);
|
||||
}
|
||||
}
|
||||
return props.root;
|
||||
}
|
||||
|
||||
const root = extractRoot();
|
||||
const isOpMuted = root && isMuted(root.pubkey);
|
||||
const opt = {
|
||||
showHeader: ev?.Kind === EventKind.Repost,
|
||||
showFooter: false,
|
||||
};
|
||||
|
||||
return isOpMuted ? null : (
|
||||
<div className="reaction">
|
||||
<div className="header flex">
|
||||
<ProfileImage pubkey={ev.RootPubKey} />
|
||||
<div className="info">
|
||||
<NoteTime from={ev.CreatedAt * 1000} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
||||
{!root && refEvent ? (
|
||||
<p>
|
||||
<Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
65
packages/app/src/Element/NoteTime.tsx
Normal file
65
packages/app/src/Element/NoteTime.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const MinuteInMs = 1_000 * 60;
|
||||
const HourInMs = MinuteInMs * 60;
|
||||
const DayInMs = HourInMs * 24;
|
||||
|
||||
export interface NoteTimeProps {
|
||||
from: number;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
export default function NoteTime(props: NoteTimeProps) {
|
||||
const [time, setTime] = useState<string>();
|
||||
const { from, fallback } = props;
|
||||
const absoluteTime = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "long",
|
||||
}).format(from);
|
||||
const fromDate = new Date(from);
|
||||
const isoDate = fromDate.toISOString();
|
||||
|
||||
function calcTime() {
|
||||
const fromDate = new Date(from);
|
||||
const ago = new Date().getTime() - from;
|
||||
const absAgo = Math.abs(ago);
|
||||
if (absAgo > DayInMs) {
|
||||
return fromDate.toLocaleDateString(undefined, {
|
||||
year: "2-digit",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
} else if (absAgo > HourInMs) {
|
||||
return `${fromDate.getHours().toString().padStart(2, "0")}:${fromDate.getMinutes().toString().padStart(2, "0")}`;
|
||||
} else if (absAgo < MinuteInMs) {
|
||||
return fallback;
|
||||
} else {
|
||||
const mins = Math.floor(absAgo / MinuteInMs);
|
||||
if (ago < 0) {
|
||||
return `in ${mins}m`;
|
||||
}
|
||||
return `${mins}m`;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTime(calcTime());
|
||||
const t = setInterval(() => {
|
||||
setTime(s => {
|
||||
const newTime = calcTime();
|
||||
if (newTime !== s) {
|
||||
return newTime;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
}, MinuteInMs);
|
||||
return () => clearInterval(t);
|
||||
}, [from]);
|
||||
|
||||
return (
|
||||
<time dateTime={isoDate} title={absoluteTime}>
|
||||
{time}
|
||||
</time>
|
||||
);
|
||||
}
|
38
packages/app/src/Element/NoteToSelf.css
Normal file
38
packages/app/src/Element/NoteToSelf.css
Normal file
@ -0,0 +1,38 @@
|
||||
.nts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note-to-self {
|
||||
margin-left: 5px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nts .avatar-wrapper {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.nts .avatar {
|
||||
border-width: 1px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.nts .avatar.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nts a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nts .name {
|
||||
margin-top: -0.2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.nts .nip05 {
|
||||
margin: 0;
|
||||
margin-top: -0.2em;
|
||||
}
|
56
packages/app/src/Element/NoteToSelf.tsx
Normal file
56
packages/app/src/Element/NoteToSelf.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import "./NoteToSelf.css";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faBook, faCertificate } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { profileLink } from "Util";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface NoteToSelfProps {
|
||||
pubkey: string;
|
||||
clickable?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
function NoteLabel({ pubkey }: NoteToSelfProps) {
|
||||
const user = useUserProfile(pubkey);
|
||||
return (
|
||||
<div>
|
||||
<FormattedMessage {...messages.NoteToSelf} /> <FontAwesomeIcon icon={faCertificate} size="xs" />
|
||||
{user?.nip05 && <Nip05 nip05={user.nip05} pubkey={user.pubkey} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NoteToSelf({ pubkey, clickable, className, link }: NoteToSelfProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const clickLink = () => {
|
||||
if (clickable) {
|
||||
navigate(link ?? profileLink(pubkey));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`nts${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<div className={`avatar${clickable ? " clickable" : ""}`}>
|
||||
<FontAwesomeIcon onClick={clickLink} className="note-to-self" icon={faBook} size="2xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-grow">
|
||||
<div className="name">
|
||||
{(clickable && (
|
||||
<Link to={link ?? profileLink(pubkey)}>
|
||||
<NoteLabel pubkey={pubkey} />
|
||||
</Link>
|
||||
)) || <NoteLabel pubkey={pubkey} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
32
packages/app/src/Element/ProfileImage.css
Normal file
32
packages/app/src/Element/ProfileImage.css
Normal file
@ -0,0 +1,32 @@
|
||||
.pfp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pfp .avatar-wrapper {
|
||||
margin-right: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pfp .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pfp a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pfp .username {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pfp .profile-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
71
packages/app/src/Element/ProfileImage.tsx
Normal file
71
packages/app/src/Element/ProfileImage.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import "./ProfileImage.css";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { hexToBech32, profileLink } from "Util";
|
||||
import Avatar from "Element/Avatar";
|
||||
import Nip05 from "Element/Nip05";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { MetadataCache } from "State/Users";
|
||||
|
||||
export interface ProfileImageProps {
|
||||
pubkey: HexKey;
|
||||
subHeader?: JSX.Element;
|
||||
showUsername?: boolean;
|
||||
className?: string;
|
||||
link?: string;
|
||||
defaultNip?: string;
|
||||
verifyNip?: boolean;
|
||||
}
|
||||
|
||||
export default function ProfileImage({
|
||||
pubkey,
|
||||
subHeader,
|
||||
showUsername = true,
|
||||
className,
|
||||
link,
|
||||
defaultNip,
|
||||
verifyNip,
|
||||
}: ProfileImageProps) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserProfile(pubkey);
|
||||
const nip05 = defaultNip ? defaultNip : user?.nip05;
|
||||
|
||||
const name = useMemo(() => {
|
||||
return getDisplayName(user, pubkey);
|
||||
}, [user, pubkey]);
|
||||
|
||||
if (!pubkey && !link) {
|
||||
link = "#";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`pfp${className ? ` ${className}` : ""}`}>
|
||||
<div className="avatar-wrapper">
|
||||
<Avatar user={user} onClick={() => navigate(link ?? profileLink(pubkey))} />
|
||||
</div>
|
||||
{showUsername && (
|
||||
<div className="profile-name f-grow">
|
||||
<div className="username">
|
||||
<Link className="display-name" key={pubkey} to={link ?? profileLink(pubkey)}>
|
||||
{name}
|
||||
{nip05 && <Nip05 nip05={nip05} pubkey={pubkey} verifyNip={verifyNip} />}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="subheader">{subHeader}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
||||
if (user?.display_name !== undefined && user.display_name.length > 0) {
|
||||
name = user.display_name;
|
||||
} else if (user?.name !== undefined && user.name.length > 0) {
|
||||
name = user.name;
|
||||
}
|
||||
return name;
|
||||
}
|
15
packages/app/src/Element/ProfilePreview.css
Normal file
15
packages/app/src/Element/ProfilePreview.css
Normal file
@ -0,0 +1,15 @@
|
||||
.profile-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.profile-preview .pfp {
|
||||
flex-grow: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.profile-preview .about {
|
||||
font-size: small;
|
||||
color: var(--gray-light);
|
||||
}
|
44
packages/app/src/Element/ProfilePreview.tsx
Normal file
44
packages/app/src/Element/ProfilePreview.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import "./ProfilePreview.css";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import FollowButton from "Element/FollowButton";
|
||||
import { useUserProfile } from "Feed/ProfileFeed";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
export interface ProfilePreviewProps {
|
||||
pubkey: HexKey;
|
||||
options?: {
|
||||
about?: boolean;
|
||||
};
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export default function ProfilePreview(props: ProfilePreviewProps) {
|
||||
const pubkey = props.pubkey;
|
||||
const user = useUserProfile(pubkey);
|
||||
const { ref, inView } = useInView({ triggerOnce: true });
|
||||
const options = {
|
||||
about: true,
|
||||
...props.options,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`profile-preview${props.className ? ` ${props.className}` : ""}`} ref={ref}>
|
||||
{inView && (
|
||||
<>
|
||||
<ProfileImage
|
||||
pubkey={pubkey}
|
||||
subHeader={options.about ? <div className="f-ellipsis about">{user?.about}</div> : undefined}
|
||||
/>
|
||||
{props.actions ?? (
|
||||
<div className="follow-button-container">
|
||||
<FollowButton pubkey={pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
22
packages/app/src/Element/ProxyImg.tsx
Normal file
22
packages/app/src/Element/ProxyImg.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import useImgProxy from "Feed/ImgProxy";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ProxyImgProps extends React.DetailedHTMLProps<React.ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ProxyImg = (props: ProxyImgProps) => {
|
||||
const { src, size, ...rest } = props;
|
||||
const [url, setUrl] = useState<string>();
|
||||
const { proxy } = useImgProxy();
|
||||
|
||||
useEffect(() => {
|
||||
if (src) {
|
||||
proxy(src, size)
|
||||
.then(a => setUrl(a))
|
||||
.catch(console.warn);
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
return <img src={url} {...rest} />;
|
||||
};
|
50
packages/app/src/Element/QrCode.tsx
Normal file
50
packages/app/src/Element/QrCode.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface QrCodeProps {
|
||||
data?: string;
|
||||
link?: string;
|
||||
avatar?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function QrCode(props: QrCodeProps) {
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ((props.data?.length ?? 0) > 0 && qrRef.current) {
|
||||
const qr = new QRCodeStyling({
|
||||
width: props.width || 256,
|
||||
height: props.height || 256,
|
||||
data: props.data,
|
||||
margin: 5,
|
||||
type: "canvas",
|
||||
image: props.avatar,
|
||||
dotsOptions: {
|
||||
type: "rounded",
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: "extra-rounded",
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
});
|
||||
qrRef.current.innerHTML = "";
|
||||
qr.append(qrRef.current);
|
||||
if (props.link) {
|
||||
qrRef.current.onclick = function () {
|
||||
const elm = document.createElement("a");
|
||||
elm.href = props.link ?? "";
|
||||
elm.click();
|
||||
};
|
||||
}
|
||||
} else if (qrRef.current) {
|
||||
qrRef.current.innerHTML = "";
|
||||
}
|
||||
}, [props.data, props.link]);
|
||||
|
||||
return <div className={`qr${props.className ?? ""}`} ref={qrRef}></div>;
|
||||
}
|
122
packages/app/src/Element/Reactions.css
Normal file
122
packages/app/src/Element/Reactions.css
Normal file
@ -0,0 +1,122 @@
|
||||
.reactions-modal .modal-body {
|
||||
padding: 0;
|
||||
max-width: 586px;
|
||||
}
|
||||
|
||||
.reactions-view {
|
||||
padding: 24px 32px;
|
||||
background-color: #1b1b1b;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.light .reactions-view {
|
||||
background-color: var(--note-bg);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.reactions-view {
|
||||
padding: 12px 16px;
|
||||
margin-top: -160px;
|
||||
}
|
||||
}
|
||||
|
||||
.reactions-view .close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
color: var(--font-secondary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reactions-view .close:hover {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.reactions-view .reactions-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reactions-view .reactions-header h2 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.reactions-view .body {
|
||||
overflow: scroll;
|
||||
height: 320px;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.reactions-view .body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reactions-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.reactions-item .reaction-icon {
|
||||
width: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reactions-item .follow-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reactions-item .zap-reaction-icon {
|
||||
width: 52px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reactions-item .zap-amount {
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.reactions-view .tab.disabled {
|
||||
display: none;
|
||||
}
|
||||
.reactions-item .reaction-icon {
|
||||
width: 42px;
|
||||
}
|
||||
.reactions-item .avatar {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
.reactions-item .pfp .username {
|
||||
font-size: 14px;
|
||||
}
|
||||
.reactions-item .pfp .nip05 {
|
||||
display: none;
|
||||
}
|
||||
.reactions-item button {
|
||||
font-size: 14px;
|
||||
}
|
||||
.reactions-item .zap-reaction-icon svg {
|
||||
width: 12px;
|
||||
height: l2px;
|
||||
}
|
||||
.reactions-item .zap-amount {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
145
packages/app/src/Element/Reactions.tsx
Normal file
145
packages/app/src/Element/Reactions.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import "./Reactions.css";
|
||||
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { TaggedRawEvent } from "@snort/nostr";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import Dislike from "Icons/Dislike";
|
||||
import Heart from "Icons/Heart";
|
||||
import ZapIcon from "Icons/Zap";
|
||||
import { Tab } from "Element/Tabs";
|
||||
import { ParsedZap } from "Element/Zap";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Tabs from "Element/Tabs";
|
||||
import Close from "Icons/Close";
|
||||
import Modal from "Element/Modal";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface ReactionsProps {
|
||||
show: boolean;
|
||||
setShow(b: boolean): void;
|
||||
positive: TaggedRawEvent[];
|
||||
negative: TaggedRawEvent[];
|
||||
reposts: TaggedRawEvent[];
|
||||
zaps: ParsedZap[];
|
||||
}
|
||||
|
||||
const Reactions = ({ show, setShow, positive, negative, reposts, zaps }: ReactionsProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const onClose = () => setShow(false);
|
||||
const likes = useMemo(() => {
|
||||
const sorted = [...positive];
|
||||
sorted.sort((a, b) => b.created_at - a.created_at);
|
||||
return sorted;
|
||||
}, [positive]);
|
||||
const dislikes = useMemo(() => {
|
||||
const sorted = [...negative];
|
||||
sorted.sort((a, b) => b.created_at - a.created_at);
|
||||
return sorted;
|
||||
}, [negative]);
|
||||
const total = positive.length + negative.length + zaps.length + reposts.length;
|
||||
const defaultTabs: Tab[] = [
|
||||
{
|
||||
text: formatMessage(messages.Likes, { n: likes.length }),
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
text: formatMessage(messages.Zaps, { n: zaps.length }),
|
||||
value: 1,
|
||||
disabled: zaps.length === 0,
|
||||
},
|
||||
{
|
||||
text: formatMessage(messages.Reposts, { n: reposts.length }),
|
||||
value: 2,
|
||||
disabled: reposts.length === 0,
|
||||
},
|
||||
];
|
||||
const tabs = defaultTabs.concat(
|
||||
dislikes.length !== 0
|
||||
? [
|
||||
{
|
||||
text: formatMessage(messages.Dislikes, { n: dislikes.length }),
|
||||
value: 3,
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState(tabs[0]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
setTab(tabs[0]);
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
return show ? (
|
||||
<Modal className="reactions-modal" onClose={onClose}>
|
||||
<div className="reactions-view">
|
||||
<div className="close" onClick={onClose}>
|
||||
<Close />
|
||||
</div>
|
||||
<div className="reactions-header">
|
||||
<h2>
|
||||
<FormattedMessage {...messages.ReactionsCount} values={{ n: total }} />
|
||||
</h2>
|
||||
</div>
|
||||
<Tabs tabs={tabs} tab={tab} setTab={setTab} />
|
||||
<div className="body" key={tab.value}>
|
||||
{tab.value === 0 &&
|
||||
likes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
{ev.content === "+" ? <Heart width={20} height={18} /> : ev.content}
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 1 &&
|
||||
zaps.map(z => {
|
||||
return (
|
||||
z.zapper && (
|
||||
<div key={z.id} className="reactions-item">
|
||||
<div className="zap-reaction-icon">
|
||||
<ZapIcon width={17} height={20} />
|
||||
<span className="zap-amount">{formatShort(z.amount)}</span>
|
||||
</div>
|
||||
<ProfileImage pubkey={z.zapper} subHeader={<>{z.content}</>} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{tab.value === 2 &&
|
||||
reposts.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Heart width={20} height={18} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tab.value === 3 &&
|
||||
dislikes.map(ev => {
|
||||
return (
|
||||
<div key={ev.id} className="reactions-item">
|
||||
<div className="reaction-icon">
|
||||
<Dislike width={20} height={18} />
|
||||
</div>
|
||||
<ProfileImage pubkey={ev.pubkey} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default Reactions;
|
42
packages/app/src/Element/Relay.css
Normal file
42
packages/app/src/Element/Relay.css
Normal file
@ -0,0 +1,42 @@
|
||||
.relay {
|
||||
margin-top: 10px;
|
||||
background-color: var(--gray-secondary);
|
||||
border-radius: 5px;
|
||||
text-align: start;
|
||||
display: grid;
|
||||
grid-template-columns: min-content auto;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.relay > div {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.relay-extra {
|
||||
padding: 5px;
|
||||
margin: 0 5px;
|
||||
background-color: var(--gray-tertiary);
|
||||
border-radius: 0 0 5px 5px;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background-color: var(--gray);
|
||||
user-select: none;
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
margin-left: 0.5em;
|
||||
padding: 2px 10px;
|
||||
background-color: var(--gray);
|
||||
border-radius: 10px;
|
||||
}
|
107
packages/app/src/Element/Relay.tsx
Normal file
107
packages/app/src/Element/Relay.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import "./Relay.css";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
faPlug,
|
||||
faSquareCheck,
|
||||
faSquareXmark,
|
||||
faWifi,
|
||||
faPlugCircleXmark,
|
||||
faGear,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import useRelayState from "Feed/RelayState";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { setRelays } from "State/Login";
|
||||
import { RootState } from "State/Store";
|
||||
import { RelaySettings } from "@snort/nostr";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
export interface RelayProps {
|
||||
addr: string;
|
||||
}
|
||||
|
||||
export default function Relay(props: RelayProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const allRelaySettings = useSelector<RootState, Record<string, RelaySettings>>(s => s.login.relays);
|
||||
const relaySettings = allRelaySettings[props.addr];
|
||||
const state = useRelayState(props.addr);
|
||||
const name = useMemo(() => new URL(props.addr).host, [props.addr]);
|
||||
|
||||
function configure(o: RelaySettings) {
|
||||
dispatch(
|
||||
setRelays({
|
||||
relays: {
|
||||
...allRelaySettings,
|
||||
[props.addr]: o,
|
||||
},
|
||||
createdAt: Math.floor(new Date().getTime() / 1000),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const latency = Math.floor(state?.avgLatency ?? 0);
|
||||
return (
|
||||
<>
|
||||
<div className={`relay w-max`}>
|
||||
<div className={`flex ${state?.connected ? "bg-success" : "bg-error"}`}>
|
||||
<FontAwesomeIcon icon={faPlug} />
|
||||
</div>
|
||||
<div className="f-grow f-col">
|
||||
<div className="flex mb10">
|
||||
<b className="f-2">{name}</b>
|
||||
<div className="f-1">
|
||||
<FormattedMessage {...messages.Write} />
|
||||
<span
|
||||
className="checkmark"
|
||||
onClick={() =>
|
||||
configure({
|
||||
write: !relaySettings.write,
|
||||
read: relaySettings.read,
|
||||
})
|
||||
}>
|
||||
<FontAwesomeIcon icon={relaySettings.write ? faSquareCheck : faSquareXmark} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="f-1">
|
||||
<FormattedMessage {...messages.Read} />
|
||||
<span
|
||||
className="checkmark"
|
||||
onClick={() =>
|
||||
configure({
|
||||
write: relaySettings.write,
|
||||
read: !relaySettings.read,
|
||||
})
|
||||
}>
|
||||
<FontAwesomeIcon icon={relaySettings.read ? faSquareCheck : faSquareXmark} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="f-grow">
|
||||
<FontAwesomeIcon icon={faWifi} />{" "}
|
||||
{latency > 2000
|
||||
? formatMessage(messages.Seconds, {
|
||||
n: (latency / 1000).toFixed(0),
|
||||
})
|
||||
: formatMessage(messages.Milliseconds, {
|
||||
n: latency.toLocaleString(),
|
||||
})}
|
||||
|
||||
<FontAwesomeIcon icon={faPlugCircleXmark} /> {state?.disconnects}
|
||||
</div>
|
||||
<div>
|
||||
<span className="icon-btn" onClick={() => navigate(state?.id ?? "")}>
|
||||
<FontAwesomeIcon icon={faGear} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
36
packages/app/src/Element/RelaysMetadata.css
Normal file
36
packages/app/src/Element/RelaysMetadata.css
Normal file
@ -0,0 +1,36 @@
|
||||
.favicon {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
border-radius: 100%;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.relay-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.relay-settings {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.relay-settings svg:not(:last-child) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
.relay-settings svg.enabled {
|
||||
color: var(--highlight);
|
||||
}
|
||||
.relay-settings svg.disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.relay-url {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 520px) {
|
||||
.relay-url {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
44
packages/app/src/Element/RelaysMetadata.tsx
Normal file
44
packages/app/src/Element/RelaysMetadata.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import "./RelaysMetadata.css";
|
||||
import Nostrich from "nostrich.webp";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FullRelaySettings } from "@snort/nostr";
|
||||
import Read from "Icons/Read";
|
||||
import Write from "Icons/Write";
|
||||
|
||||
const RelayFavicon = ({ url }: { url: string }) => {
|
||||
const cleanUrl = url
|
||||
.replace("wss://relay.", "https://")
|
||||
.replace("wss://nostr.", "https://")
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.replace(/\/$/, "");
|
||||
const [faviconUrl, setFaviconUrl] = useState(`${cleanUrl}/favicon.ico`);
|
||||
|
||||
return <img className="favicon" src={faviconUrl} onError={() => setFaviconUrl(Nostrich)} />;
|
||||
};
|
||||
|
||||
interface RelaysMetadataProps {
|
||||
relays: FullRelaySettings[];
|
||||
}
|
||||
|
||||
const RelaysMetadata = ({ relays }: RelaysMetadataProps) => {
|
||||
return (
|
||||
<div className="main-content">
|
||||
{relays?.map(({ url, settings }) => {
|
||||
return (
|
||||
<div className="card relay-card">
|
||||
<RelayFavicon url={url} />
|
||||
<code className="relay-url">{url}</code>
|
||||
<div className="relay-settings">
|
||||
<Read className={settings.read ? "enabled" : "disabled"} />
|
||||
<Write className={settings.write ? "enabled" : "disabled"} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelaysMetadata;
|
167
packages/app/src/Element/SendSats.css
Normal file
167
packages/app/src/Element/SendSats.css
Normal file
@ -0,0 +1,167 @@
|
||||
.lnurl-modal .modal-body {
|
||||
padding: 0;
|
||||
max-width: 470px;
|
||||
}
|
||||
|
||||
.lnurl-modal .lnurl-tip .pfp .avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.lnurl-tip {
|
||||
padding: 24px 32px;
|
||||
background-color: #1b1b1b;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.lnurl-tip {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.light .lnurl-tip {
|
||||
background-color: var(--note-bg);
|
||||
}
|
||||
|
||||
.lnurl-tip h3 {
|
||||
color: var(--font-secondary-color);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.11em;
|
||||
font-weight: 600;
|
||||
line-height: 13px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lnurl-tip .close {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 16px;
|
||||
color: var(--font-secondary-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lnurl-tip .close:hover {
|
||||
color: var(--font-tertiary-color);
|
||||
}
|
||||
|
||||
.lnurl-tip .lnurl-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.lnurl-tip .lnurl-header h2 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.amounts {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.amounts::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sat-amount {
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
background-color: #2a2a2a;
|
||||
color: var(--font-color);
|
||||
padding: 12px 16px;
|
||||
border-radius: 100px;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.light .sat-amount {
|
||||
background-color: var(--gray);
|
||||
}
|
||||
|
||||
.sat-amount:not(:last-child) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sat-amount:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sat-amount.active {
|
||||
font-weight: bold;
|
||||
color: var(--note-bg);
|
||||
background-color: var(--font-color);
|
||||
}
|
||||
|
||||
.lnurl-tip .invoice {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lnurl-tip .invoice .actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lnurl-tip .invoice .actions .copy-action {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.lnurl-tip .invoice .actions .wallet-action {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.lnurl-tip .zap-action {
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.lnurl-tip .zap-action svg {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.lnurl-tip .zap-action-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.lnurl-tip .custom-amount {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.lnurl-tip .custom-amount button {
|
||||
padding: 12px 18px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.lnurl-tip canvas {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.lnurl-tip .success-action .paid {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.lnurl-tip .success-action a {
|
||||
color: var(--highlight);
|
||||
font-size: 19px;
|
||||
}
|
319
packages/app/src/Element/SendSats.tsx
Normal file
319
packages/app/src/Element/SendSats.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import "./SendSats.css";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useIntl, FormattedMessage } from "react-intl";
|
||||
|
||||
import { formatShort } from "Number";
|
||||
import { bech32ToText } from "Util";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import Check from "Icons/Check";
|
||||
import Zap from "Icons/Zap";
|
||||
import Close from "Icons/Close";
|
||||
import useEventPublisher from "Feed/EventPublisher";
|
||||
import ProfileImage from "Element/ProfileImage";
|
||||
import Modal from "Element/Modal";
|
||||
import QrCode from "Element/QrCode";
|
||||
import Copy from "Element/Copy";
|
||||
import useWebln from "Hooks/useWebln";
|
||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface LNURLService {
|
||||
nostrPubkey?: HexKey;
|
||||
minSendable?: number;
|
||||
maxSendable?: number;
|
||||
metadata: string;
|
||||
callback: string;
|
||||
commentAllowed?: number;
|
||||
}
|
||||
|
||||
interface LNURLInvoice {
|
||||
pr: string;
|
||||
successAction?: LNURLSuccessAction;
|
||||
}
|
||||
|
||||
interface LNURLSuccessAction {
|
||||
description?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface LNURLTipProps {
|
||||
onClose?: () => void;
|
||||
svc?: string;
|
||||
show?: boolean;
|
||||
invoice?: string; // shortcut to invoice qr tab
|
||||
title?: string;
|
||||
notice?: string;
|
||||
target?: string;
|
||||
note?: HexKey;
|
||||
author?: HexKey;
|
||||
}
|
||||
|
||||
export default function LNURLTip(props: LNURLTipProps) {
|
||||
const onClose = props.onClose || (() => undefined);
|
||||
const service = props.svc;
|
||||
const show = props.show || false;
|
||||
const { note, author, target } = props;
|
||||
const amounts = [500, 1_000, 5_000, 10_000, 20_000, 50_000, 100_000, 1_000_000];
|
||||
const emojis: Record<number, string> = {
|
||||
1_000: "👍",
|
||||
5_000: "💜",
|
||||
10_000: "😍",
|
||||
20_000: "🤩",
|
||||
50_000: "🔥",
|
||||
100_000: "🚀",
|
||||
1_000_000: "🤯",
|
||||
};
|
||||
const [payService, setPayService] = useState<LNURLService>();
|
||||
const [amount, setAmount] = useState<number>(500);
|
||||
const [customAmount, setCustomAmount] = useState<number>();
|
||||
const [invoice, setInvoice] = useState<LNURLInvoice>();
|
||||
const [comment, setComment] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [success, setSuccess] = useState<LNURLSuccessAction>();
|
||||
const webln = useWebln(show);
|
||||
const { formatMessage } = useIntl();
|
||||
const publisher = useEventPublisher();
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
const canComment = (payService?.commentAllowed ?? 0) > 0 || payService?.nostrPubkey;
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !props.invoice) {
|
||||
loadService()
|
||||
.then(a => setPayService(a ?? undefined))
|
||||
.catch(() => setError(formatMessage(messages.LNURLFail)));
|
||||
} else {
|
||||
setPayService(undefined);
|
||||
setError(undefined);
|
||||
setInvoice(props.invoice ? { pr: props.invoice } : undefined);
|
||||
setAmount(500);
|
||||
setComment(undefined);
|
||||
setSuccess(undefined);
|
||||
}
|
||||
}, [show, service]);
|
||||
|
||||
const serviceAmounts = useMemo(() => {
|
||||
if (payService) {
|
||||
const min = (payService.minSendable ?? 0) / 1000;
|
||||
const max = (payService.maxSendable ?? 0) / 1000;
|
||||
return amounts.filter(a => a >= min && a <= max);
|
||||
}
|
||||
return [];
|
||||
}, [payService]);
|
||||
|
||||
const selectAmount = (a: number) => {
|
||||
setError(undefined);
|
||||
setInvoice(undefined);
|
||||
setAmount(a);
|
||||
};
|
||||
|
||||
async function fetchJson<T>(url: string) {
|
||||
const rsp = await fetch(url);
|
||||
if (rsp.ok) {
|
||||
const data: T = await rsp.json();
|
||||
console.log(data);
|
||||
setError(undefined);
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadService(): Promise<LNURLService | null> {
|
||||
if (service) {
|
||||
const isServiceUrl = service.toLowerCase().startsWith("lnurl");
|
||||
if (isServiceUrl) {
|
||||
const serviceUrl = bech32ToText(service);
|
||||
return await fetchJson(serviceUrl);
|
||||
} else {
|
||||
const ns = service.split("@");
|
||||
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadInvoice() {
|
||||
if (!amount || !payService) return null;
|
||||
let url = "";
|
||||
const amountParam = `amount=${Math.floor(amount * 1000)}`;
|
||||
const commentParam = comment && payService?.commentAllowed ? `&comment=${encodeURIComponent(comment)}` : "";
|
||||
if (payService.nostrPubkey && author) {
|
||||
const ev = await publisher.zap(author, note, comment);
|
||||
const nostrParam = ev && `&nostr=${encodeURIComponent(JSON.stringify(ev.ToObject()))}`;
|
||||
url = `${payService.callback}?${amountParam}${commentParam}${nostrParam}`;
|
||||
} else {
|
||||
url = `${payService.callback}?${amountParam}${commentParam}`;
|
||||
}
|
||||
try {
|
||||
const rsp = await fetch(url);
|
||||
if (rsp.ok) {
|
||||
const data = await rsp.json();
|
||||
console.log(data);
|
||||
if (data.status === "ERROR") {
|
||||
setError(data.reason);
|
||||
} else {
|
||||
setInvoice(data);
|
||||
setError("");
|
||||
payWebLNIfEnabled(data);
|
||||
}
|
||||
} else {
|
||||
setError(formatMessage(messages.InvoiceFail));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatMessage(messages.InvoiceFail));
|
||||
}
|
||||
}
|
||||
|
||||
function custom() {
|
||||
const min = (payService?.minSendable ?? 1000) / 1000;
|
||||
const max = (payService?.maxSendable ?? 21_000_000_000) / 1000;
|
||||
return (
|
||||
<div className="custom-amount flex">
|
||||
<input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
className="f-grow mr10"
|
||||
placeholder={formatMessage(messages.Custom)}
|
||||
value={customAmount}
|
||||
onChange={e => setCustomAmount(parseInt(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
disabled={!customAmount}
|
||||
onClick={() => selectAmount(customAmount ?? 0)}>
|
||||
<FormattedMessage {...messages.Confirm} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function payWebLNIfEnabled(invoice: LNURLInvoice) {
|
||||
try {
|
||||
if (webln?.enabled) {
|
||||
const res = await webln.sendPayment(invoice?.pr ?? "");
|
||||
console.log(res);
|
||||
setSuccess(invoice?.successAction ?? {});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(e);
|
||||
if (e instanceof Error) {
|
||||
setError(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function invoiceForm() {
|
||||
if (invoice) return null;
|
||||
return (
|
||||
<>
|
||||
<h3>
|
||||
<FormattedMessage {...messages.ZapAmount} />
|
||||
</h3>
|
||||
<div className="amounts" ref={horizontalScroll}>
|
||||
{serviceAmounts.map(a => (
|
||||
<span className={`sat-amount ${amount === a ? "active" : ""}`} key={a} onClick={() => selectAmount(a)}>
|
||||
{emojis[a] && <>{emojis[a]} </>}
|
||||
{formatShort(a)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{payService && custom()}
|
||||
<div className="flex">
|
||||
{canComment && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={formatMessage(messages.Comment)}
|
||||
className="f-grow"
|
||||
maxLength={payService?.commentAllowed || 120}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(amount ?? 0) > 0 && (
|
||||
<button type="button" className="zap-action" onClick={() => loadInvoice()}>
|
||||
<div className="zap-action-container">
|
||||
<Zap />
|
||||
{target ? (
|
||||
<FormattedMessage {...messages.ZapTarget} values={{ target, n: formatShort(amount) }} />
|
||||
) : (
|
||||
<FormattedMessage {...messages.ZapSats} values={{ n: formatShort(amount) }} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function payInvoice() {
|
||||
if (success) return null;
|
||||
const pr = invoice?.pr;
|
||||
return (
|
||||
<>
|
||||
<div className="invoice">
|
||||
{props.notice && <b className="error">{props.notice}</b>}
|
||||
<QrCode data={pr} link={`lightning:${pr}`} />
|
||||
<div className="actions">
|
||||
{pr && (
|
||||
<>
|
||||
<div className="copy-action">
|
||||
<Copy text={pr} maxSize={26} />
|
||||
</div>
|
||||
<button className="wallet-action" type="button" onClick={() => window.open(`lightning:${pr}`)}>
|
||||
<FormattedMessage {...messages.OpenWallet} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function successAction() {
|
||||
if (!success) return null;
|
||||
return (
|
||||
<div className="success-action">
|
||||
<p className="paid">
|
||||
<Check className="success mr10" />
|
||||
{success?.description ?? <FormattedMessage {...messages.Paid} />}
|
||||
</p>
|
||||
{success.url && (
|
||||
<p>
|
||||
<a href={success.url} rel="noreferrer" target="_blank">
|
||||
{success.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultTitle = payService?.nostrPubkey ? formatMessage(messages.SendZap) : formatMessage(messages.SendSats);
|
||||
const title = target
|
||||
? formatMessage(messages.ToTarget, {
|
||||
action: defaultTitle,
|
||||
target,
|
||||
})
|
||||
: defaultTitle;
|
||||
if (!show) return null;
|
||||
return (
|
||||
<Modal className="lnurl-modal" onClose={onClose}>
|
||||
<div className="lnurl-tip" onClick={e => e.stopPropagation()}>
|
||||
<div className="close" onClick={onClose}>
|
||||
<Close />
|
||||
</div>
|
||||
<div className="lnurl-header">
|
||||
{author && <ProfileImage pubkey={author} showUsername={false} />}
|
||||
<h2>{props.title || title}</h2>
|
||||
</div>
|
||||
{invoiceForm()}
|
||||
{error && <p className="error">{error}</p>}
|
||||
{payInvoice()}
|
||||
{successAction()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
14
packages/app/src/Element/ShowMore.css
Normal file
14
packages/app/src/Element/ShowMore.css
Normal file
@ -0,0 +1,14 @@
|
||||
.show-more {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--highlight);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.show-more:hover {
|
||||
color: var(--highlight);
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
}
|
25
packages/app/src/Element/ShowMore.tsx
Normal file
25
packages/app/src/Element/ShowMore.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import "./ShowMore.css";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
import messages from "./messages";
|
||||
|
||||
interface ShowMoreProps {
|
||||
text?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ShowMore = ({ text, onClick, className = "" }: ShowMoreProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const defaultText = formatMessage(messages.ShowMore);
|
||||
const classNames = className ? `show-more ${className}` : "show-more";
|
||||
return (
|
||||
<div className="show-more-container">
|
||||
<button className={classNames} onClick={onClick}>
|
||||
{text || defaultText}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowMore;
|
42
packages/app/src/Element/Skeleton.css
Normal file
42
packages/app/src/Element/Skeleton.css
Normal file
@ -0,0 +1,42 @@
|
||||
.skeleton {
|
||||
display: inline-block;
|
||||
height: 1em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #dddbdd;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0,
|
||||
rgba(255, 255, 255, 0.2) 20%,
|
||||
rgba(255, 255, 255, 0.5) 60%,
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
animation: shimmer 2s infinite;
|
||||
content: "";
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
.skeleton {
|
||||
background-color: #50535a;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
background-image: linear-gradient(90deg, #50535a 0%, #656871 20%, #50535a 40%, #50535a 100%);
|
||||
}
|
||||
}
|
21
packages/app/src/Element/Skeleton.tsx
Normal file
21
packages/app/src/Element/Skeleton.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import "./Skeleton.css";
|
||||
|
||||
interface ISkepetonProps {
|
||||
children?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
width?: string;
|
||||
height?: string;
|
||||
margin?: string;
|
||||
}
|
||||
|
||||
export default function Skeleton({ children, width, height, margin, loading = true }: ISkepetonProps) {
|
||||
if (!loading) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="skeleton" style={{ width: width, height: height, margin: margin }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
12
packages/app/src/Element/SoundCloudEmded.tsx
Normal file
12
packages/app/src/Element/SoundCloudEmded.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
const SoundCloudEmbed = ({ link }: { link: string }) => {
|
||||
return (
|
||||
<iframe
|
||||
width="100%"
|
||||
height="166"
|
||||
scrolling="no"
|
||||
allow="autoplay"
|
||||
src={`https://w.soundcloud.com/player/?url=${link}`}></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoundCloudEmbed;
|
16
packages/app/src/Element/SpotifyEmbed.tsx
Normal file
16
packages/app/src/Element/SpotifyEmbed.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
const SpotifyEmbed = ({ link }: { link: string }) => {
|
||||
const convertedUrl = link.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
|
||||
|
||||
return (
|
||||
<iframe
|
||||
style={{ borderRadius: 12 }}
|
||||
src={convertedUrl}
|
||||
width="100%"
|
||||
height="352"
|
||||
frameBorder="0"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
loading="lazy"></iframe>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotifyEmbed;
|
42
packages/app/src/Element/Tabs.css
Normal file
42
packages/app/src/Element/Tabs.css
Normal file
@ -0,0 +1,42 @@
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
overflow-x: scroll;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
color: var(--font-tertiary-color);
|
||||
border: 1px solid var(--font-tertiary-color);
|
||||
border-radius: 16px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 17px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-color: var(--font-color);
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
.tabs > div {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
41
packages/app/src/Element/Tabs.tsx
Normal file
41
packages/app/src/Element/Tabs.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import "./Tabs.css";
|
||||
import useHorizontalScroll from "Hooks/useHorizontalScroll";
|
||||
|
||||
export interface Tab {
|
||||
text: string;
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: Tab[];
|
||||
tab: Tab;
|
||||
setTab: (t: Tab) => void;
|
||||
}
|
||||
|
||||
interface TabElementProps extends Omit<TabsProps, "tabs"> {
|
||||
t: Tab;
|
||||
}
|
||||
|
||||
export const TabElement = ({ t, tab, setTab }: TabElementProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`tab ${tab.value === t.value ? "active" : ""} ${t.disabled ? "disabled" : ""}`}
|
||||
onClick={() => !t.disabled && setTab(t)}>
|
||||
{t.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tabs = ({ tabs, tab, setTab }: TabsProps) => {
|
||||
const horizontalScroll = useHorizontalScroll();
|
||||
return (
|
||||
<div className="tabs" ref={horizontalScroll}>
|
||||
{tabs.map(t => (
|
||||
<TabElement tab={tab} setTab={setTab} t={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
82
packages/app/src/Element/Text.css
Normal file
82
packages/app/src/Element/Text.css
Normal file
@ -0,0 +1,82 @@
|
||||
.text {
|
||||
font-size: var(--font-size);
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.text a {
|
||||
color: var(--highlight);
|
||||
text-decoration: none;
|
||||
}
|
||||
.text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.text h2 {
|
||||
margin: 0;
|
||||
}
|
||||
.text h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.text h4 {
|
||||
margin: 0;
|
||||
}
|
||||
.text h5 {
|
||||
margin: 0;
|
||||
}
|
||||
.text h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text p {
|
||||
margin: 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.text li {
|
||||
margin-top: -1em;
|
||||
}
|
||||
.text li:last-child {
|
||||
margin-bottom: -2em;
|
||||
}
|
||||
|
||||
.text hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background-image: var(--gray-gradient);
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.text img,
|
||||
.text video,
|
||||
.text iframe,
|
||||
.text audio {
|
||||
max-width: 100%;
|
||||
max-height: 500px;
|
||||
margin: 10px auto;
|
||||
display: block;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.text iframe,
|
||||
.text video {
|
||||
width: -webkit-fill-available;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.text blockquote {
|
||||
margin: 0;
|
||||
color: var(--font-secondary-color);
|
||||
border-left: 2px solid var(--font-secondary-color);
|
||||
padding-left: 12px;
|
||||
}
|
184
packages/app/src/Element/Text.tsx
Normal file
184
packages/app/src/Element/Text.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import "./Text.css";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { visit, SKIP } from "unist-util-visit";
|
||||
|
||||
import { UrlRegex, MentionRegex, InvoiceRegex, HashtagRegex } from "Const";
|
||||
import { eventLink, hexToBech32, unwrap } from "Util";
|
||||
import Invoice from "Element/Invoice";
|
||||
import Hashtag from "Element/Hashtag";
|
||||
|
||||
import { Tag } from "@snort/nostr";
|
||||
import { MetadataCache } from "State/Users";
|
||||
import Mention from "Element/Mention";
|
||||
import HyperText from "Element/HyperText";
|
||||
import { HexKey } from "@snort/nostr";
|
||||
import * as unist from "unist";
|
||||
|
||||
export type Fragment = string | React.ReactNode;
|
||||
|
||||
export interface TextFragment {
|
||||
body: React.ReactNode[];
|
||||
tags: Tag[];
|
||||
users: Map<string, MetadataCache>;
|
||||
}
|
||||
|
||||
export interface TextProps {
|
||||
content: string;
|
||||
creator: HexKey;
|
||||
tags: Tag[];
|
||||
users: Map<string, MetadataCache>;
|
||||
}
|
||||
|
||||
export default function Text({ content, tags, creator, users }: TextProps) {
|
||||
function extractLinks(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(UrlRegex).map(a => {
|
||||
if (a.startsWith("http")) {
|
||||
return <HyperText key={a} link={a} creator={creator} />;
|
||||
}
|
||||
return a;
|
||||
});
|
||||
}
|
||||
return f;
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractMentions(frag: TextFragment) {
|
||||
return frag.body
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(MentionRegex).map(match => {
|
||||
const matchTag = match.match(/#\[(\d+)\]/);
|
||||
if (matchTag && matchTag.length === 2) {
|
||||
const idx = parseInt(matchTag[1]);
|
||||
const ref = frag.tags?.find(a => a.Index === idx);
|
||||
if (ref) {
|
||||
switch (ref.Key) {
|
||||
case "p": {
|
||||
return <Mention key={ref.PubKey} pubkey={ref.PubKey ?? ""} />;
|
||||
}
|
||||
case "e": {
|
||||
const eText = hexToBech32("note", ref.Event).substring(0, 12);
|
||||
return (
|
||||
<Link key={ref.Event} to={eventLink(ref.Event ?? "")} onClick={e => e.stopPropagation()}>
|
||||
#{eText}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
case "t": {
|
||||
return <Hashtag key={ref.Hashtag} tag={ref.Hashtag ?? ""} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
return <b style={{ color: "var(--error)" }}>{matchTag[0]}?</b>;
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractInvoices(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(InvoiceRegex).map(i => {
|
||||
if (i.toLowerCase().startsWith("lnbc")) {
|
||||
return <Invoice key={i} invoice={i} />;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function extractHashtags(fragments: Fragment[]) {
|
||||
return fragments
|
||||
.map(f => {
|
||||
if (typeof f === "string") {
|
||||
return f.split(HashtagRegex).map(i => {
|
||||
if (i.toLowerCase().startsWith("#")) {
|
||||
return <Hashtag key={i} tag={i.substring(1)} />;
|
||||
} else {
|
||||
return i;
|
||||
}
|
||||
});
|
||||
}
|
||||
return f;
|
||||
})
|
||||
.flat();
|
||||
}
|
||||
|
||||
function transformLi(frag: TextFragment) {
|
||||
const fragments = transformText(frag);
|
||||
return <li>{fragments}</li>;
|
||||
}
|
||||
|
||||
function transformParagraph(frag: TextFragment) {
|
||||
const fragments = transformText(frag);
|
||||
if (fragments.every(f => typeof f === "string")) {
|
||||
return <p>{fragments}</p>;
|
||||
}
|
||||
return <>{fragments}</>;
|
||||
}
|
||||
|
||||
function transformText(frag: TextFragment) {
|
||||
let fragments = extractMentions(frag);
|
||||
fragments = extractLinks(fragments);
|
||||
fragments = extractInvoices(fragments);
|
||||
fragments = extractHashtags(fragments);
|
||||
return fragments;
|
||||
}
|
||||
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
p: (x: { children?: React.ReactNode[] }) => transformParagraph({ body: x.children ?? [], tags, users }),
|
||||
a: (x: { href?: string }) => <HyperText link={x.href ?? ""} creator={creator} />,
|
||||
li: (x: { children?: Fragment[] }) => transformLi({ body: x.children ?? [], tags, users }),
|
||||
};
|
||||
}, [content]);
|
||||
|
||||
interface Node extends unist.Node<unist.Data> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const disableMarkdownLinks = useCallback(
|
||||
() => (tree: Node) => {
|
||||
visit(tree, (node, index, parent) => {
|
||||
if (
|
||||
parent &&
|
||||
typeof index === "number" &&
|
||||
(node.type === "link" ||
|
||||
node.type === "linkReference" ||
|
||||
node.type === "image" ||
|
||||
node.type === "imageReference" ||
|
||||
node.type === "definition")
|
||||
) {
|
||||
node.type = "text";
|
||||
const position = unwrap(node.position);
|
||||
node.value = content.slice(position.start.offset, position.end.offset).replace(/\)$/, " )");
|
||||
return SKIP;
|
||||
}
|
||||
});
|
||||
},
|
||||
[content]
|
||||
);
|
||||
return (
|
||||
<div dir="auto">
|
||||
<ReactMarkdown className="text" components={components} remarkPlugins={[disableMarkdownLinks]}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
69
packages/app/src/Element/Textarea.css
Normal file
69
packages/app/src/Element/Textarea.css
Normal file
@ -0,0 +1,69 @@
|
||||
.rta__list {
|
||||
border: none;
|
||||
}
|
||||
.rta__item:not(:last-child) {
|
||||
border: none;
|
||||
}
|
||||
.rta__entity--selected .user-item,
|
||||
.rta__entity--selected .emoji-item {
|
||||
text-decoration: none;
|
||||
background: var(--gray-secondary);
|
||||
}
|
||||
|
||||
.user-item,
|
||||
.emoji-item {
|
||||
color: var(--font-color);
|
||||
background: var(--note-bg);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.user-item:hover,
|
||||
.emoji-item:hover {
|
||||
background: var(--gray-tertiary);
|
||||
}
|
||||
|
||||
.user-item .picture {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.user-picture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.user-picture .avatar {
|
||||
border-width: 1px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.user-item .nip05 {
|
||||
font-size: var(--font-size-tiny);
|
||||
}
|
||||
|
||||
.emoji-item {
|
||||
font-size: var(--font-size-tiny);
|
||||
}
|
||||
|
||||
.emoji-item .emoji {
|
||||
margin-right: 0.2em;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.emoji-item .emoji-name {
|
||||
font-weight: bold;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user