From 36146b3f2826f9bcc01653a9d9e5a5b95f85b2b9 Mon Sep 17 00:00:00 2001 From: Milad Raeisi <6504337+miladsoft@users.noreply.github.com> Date: Sat, 12 Oct 2024 02:15:17 +0400 Subject: [PATCH] Add Events support (#41) * Update profile.component.html * Prepare event box and comment box for receiving and sending events and comments. * Add like, share and event preview UI to events * Add event box * Update user.ts * Update post.ts * Update notification.service.ts * Update metadata.service.ts * Update event.service.ts * Update notifications.component.ts * Update profile.component.ts * Update profile.component.html * Load user events * Fix UI * Add new service to get events * Update event service * Update event service * Update event service * Update publishEventToWriteRelays * Update event service * Clean and format code * Change events UI * Update event service * Update event service * Update event UI * Update profile.component.html * Add event list component and update profile --- angular.json | 7 +- package-lock.json | 6 +- package.json | 5 +- public/data/emoji.json | 7612 ----------------- src/@angor/angor.provider.ts | 29 +- src/@angor/animations/defaults.ts | 14 +- src/@angor/animations/expand-collapse.ts | 8 +- src/@angor/animations/fade.ts | 8 +- src/@angor/animations/shake.ts | 45 +- src/@angor/animations/slide.ts | 8 +- src/@angor/animations/zoom.ts | 8 +- .../components/alert/alert.component.ts | 26 +- src/@angor/components/card/card.component.ts | 12 +- .../components/drawer/drawer.component.ts | 27 +- .../components/drawer/drawer.service.ts | 2 +- .../highlight/highlight.component.ts | 2 +- .../components/highlight/highlight.service.ts | 19 +- .../loading-bar/loading-bar.component.ts | 13 +- .../components/masonry/masonry.component.ts | 2 +- .../components/basic/basic.component.ts | 10 +- .../components/branch/branch.component.html | 5 +- .../components/branch/branch.component.ts | 10 +- .../components/divider/divider.component.ts | 6 +- .../components/spacer/spacer.component.ts | 6 +- .../horizontal/horizontal.component.ts | 8 +- .../navigation/navigation.service.ts | 7 +- .../components/navigation/navigation.types.ts | 6 +- .../components/aside/aside.component.ts | 16 +- .../components/basic/basic.component.ts | 10 +- .../collapsable/collapsable.component.ts | 16 +- .../components/divider/divider.component.ts | 6 +- .../components/group/group.component.ts | 14 +- .../components/spacer/spacer.component.ts | 6 +- .../vertical/vertical.component.html | 2 - .../navigation/vertical/vertical.component.ts | 32 +- .../scroll-reset/scroll-reset.directive.ts | 2 +- .../scrollbar/scrollbar.directive.ts | 76 +- .../lib/mock-api/mock-api.interceptor.ts | 42 +- .../lib/mock-api/mock-api.request-handler.ts | 15 +- src/@angor/lib/mock-api/mock-api.service.ts | 39 +- .../pipes/find-by-key/find-by-key.pipe.ts | 6 +- src/@angor/services/config/config.service.ts | 6 +- src/@angor/services/config/config.types.ts | 10 +- .../confirmation/confirmation.service.ts | 4 +- .../confirmation/dialog/dialog.component.ts | 2 +- .../services/loading/loading.interceptor.ts | 2 +- .../media-watcher/media-watcher.service.ts | 2 +- .../styles/components/example-viewer.scss | 3 - src/@angor/styles/themes.scss | 1 - src/@angor/tailwind/plugins/theming.js | 76 +- src/@angor/tailwind/plugins/utilities.js | 5 - src/app/app.component.scss | 6 +- src/app/app.config.ts | 36 +- src/app/app.resolvers.ts | 4 +- src/app/app.routes.ts | 60 +- .../auth/login/login.component.html | 300 +- .../components/auth/login/login.component.ts | 56 +- .../auth/logout/logout.component.html | 3 +- .../auth/logout/logout.component.ts | 8 +- .../auth/register/register.component.html | 208 +- .../auth/register/register.component.ts | 20 +- src/app/components/chat/chat.routes.ts | 4 +- src/app/components/chat/chat.service.ts | 647 +- .../chat/chats/chats.component.html | 374 +- .../components/chat/chats/chats.component.ts | 19 +- .../contact-info/contact-info.component.html | 10 +- .../contact-info/contact-info.component.ts | 5 +- .../conversation/conversation.component.css | 11 +- .../conversation/conversation.component.html | 65 +- .../conversation/conversation.component.ts | 166 +- .../chat/new-chat/new-chat.component.html | 5 +- .../chat/new-chat/new-chat.component.ts | 15 +- .../chat/profile/profile.component.ts | 2 +- .../event-list/event-list.component.html | 239 + .../event-list/event-list.component.scss | 158 + .../event-list/event-list.component.ts | 252 + .../components/explore/explore.component.html | 237 +- .../components/explore/explore.component.ts | 260 +- src/app/components/explore/explore.routes.ts | 2 +- src/app/components/home/home.component.html | 12 +- src/app/components/home/home.component.ts | 2 +- .../components/profile/profile.component.html | 1685 ++-- .../components/profile/profile.component.scss | 51 + .../components/profile/profile.component.ts | 328 +- .../receive-dialog.component.html | 45 +- .../receive-dialog.component.scss | 23 +- .../receive-dialog.component.ts | 209 +- .../send-dialog/send-dialog.component.html | 53 +- .../send-dialog/send-dialog.component.scss | 23 +- .../zap/send-dialog/send-dialog.component.ts | 115 +- .../settings/indexer/indexer.component.html | 108 +- .../settings/indexer/indexer.component.ts | 112 +- .../settings/network/network.component.html | 15 +- .../settings/network/network.component.ts | 70 +- .../notifications.component.html | 74 +- .../notifications/notifications.component.ts | 6 +- .../settings/profile/profile.component.html | 90 +- .../settings/profile/profile.component.ts | 78 +- .../settings/relay/relay.component.html | 56 +- .../settings/relay/relay.component.ts | 26 +- .../settings/security/security.component.html | 10 +- .../settings/security/security.component.ts | 12 +- .../settings/settings.component.html | 118 +- .../components/settings/settings.component.ts | 70 +- src/app/core/auth/auth.guard.ts | 15 +- src/app/core/navigation/navigation.service.ts | 2 - src/app/interface/project.interface.ts | 4 +- .../notifications/notifications.component.ts | 44 +- .../quick-chat/quick-chat.component.html | 2 +- .../common/quick-chat/quick-chat.component.ts | 2 +- .../common/search/search.component.html | 75 +- .../layout/common/search/search.component.ts | 34 +- .../common/settings/settings.component.ts | 17 +- .../common/update/update.component.html | 10 +- .../layout/common/update/update.component.ts | 10 +- .../layout/common/user/user.component.html | 15 +- src/app/layout/common/user/user.component.ts | 57 +- src/app/layout/layout.component.html | 2 - src/app/layout/layout.component.ts | 38 +- .../layout/layouts/empty/empty.component.ts | 2 +- .../horizontal/modern/modern.component.ts | 48 +- .../vertical/classic/classic.component.html | 2 +- .../vertical/classic/classic.component.ts | 45 +- src/app/layout/navigation/api.ts | 2 +- src/app/layout/navigation/data.ts | 168 +- .../layout/navigation/navigation.services.ts | 6 +- src/app/services/auth.service.ts | 16 +- src/app/services/content-parser.service.ts | 15 - src/app/services/emoji.service.ts | 19 - src/app/services/event-emitter.service.ts | 6 - src/app/services/event.service.ts | 649 +- src/app/services/gif.service.ts | 17 +- src/app/services/hash.service.ts | 62 +- src/app/services/image-service.service.ts | 15 +- src/app/services/indexed-db.service.ts | 479 +- src/app/services/indexer.service.ts | 159 +- src/app/services/lightning.service.ts | 165 +- src/app/services/link-preview.service.ts | 21 +- src/app/services/metadata.service.ts | 427 +- src/app/services/nip05.service.ts | 16 +- src/app/services/nostr-band-api.service.ts | 29 +- src/app/services/notification.service.ts | 57 +- src/app/services/projects.service.ts | 119 +- src/app/services/relay.service.ts | 253 +- src/app/services/security.service.ts | 145 +- src/app/services/signer.service.ts | 235 +- src/app/services/social.service.ts | 41 +- src/app/services/state.service.ts | 58 +- src/app/services/update.service.ts | 60 +- .../gif-dialog/gif-dialog.component.html | 35 +- .../shared/gif-dialog/gif-dialog.component.ts | 75 +- .../password-dialog.component.html | 61 +- .../password-dialog.component.ts | 29 +- src/app/shared/pipes/ago.pipe.ts | 20 +- src/app/shared/pipes/checkmessage.pipe.ts | 84 +- src/app/shared/pipes/safe-url.pipe.ts | 12 +- src/app/shared/pipes/size.pipe.ts | 28 +- src/app/shared/pipes/stripHtml.ts | 40 +- src/app/shared/pipes/timestamp.pipe.ts | 18 +- src/app/shared/pipes/truncate.pipe.ts | 13 +- src/app/{ => shared}/utils.ts | 77 +- src/app/types/NewEvent.ts | 79 + src/app/types/gif.ts | 5 +- src/app/types/nostr.ts | 13 +- src/app/types/post.ts | 373 +- src/app/types/user.ts | 89 +- src/app/types/webln.ts | 12 +- src/index.html | 102 +- src/main.ts | 6 +- src/styles/emoji.scss | 522 +- src/styles/styles.scss | 9 +- src/webpack.config.js | 35 +- 172 files changed, 7809 insertions(+), 12458 deletions(-) delete mode 100644 public/data/emoji.json create mode 100644 src/app/components/event-list/event-list.component.html create mode 100644 src/app/components/event-list/event-list.component.scss create mode 100644 src/app/components/event-list/event-list.component.ts create mode 100644 src/app/components/profile/profile.component.scss delete mode 100644 src/app/services/content-parser.service.ts delete mode 100644 src/app/services/emoji.service.ts delete mode 100644 src/app/services/event-emitter.service.ts rename src/app/{ => shared}/utils.ts (61%) create mode 100644 src/app/types/NewEvent.ts diff --git a/angular.json b/angular.json index 742f2162..640c269b 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,12 @@ "quill-delta", "buffer", "localforage", - "moment" + "moment", + "bech32", + "bn.js", + "qrcode", + "dayjs", + "dayjs/plugin/relativeTime" ], "assets": [ { diff --git a/package-lock.json b/package-lock.json index 748a5763..d56e248c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "angor-hub", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "angor-hub", - "version": "0.0.7", + "version": "0.0.8", "dependencies": { "@angular-builders/custom-webpack": "^18.0.0", "@angular/animations": "18.2.6", @@ -91,7 +91,7 @@ "karma-jasmine-html-reporter": "2.1.0", "lodash": "4.17.21", "postcss": "8.4.47", - "prettier": "3.3.3", + "prettier": "^3.3.3", "prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-tailwindcss": "0.6.8", "tailwindcss": "3.4.13", diff --git a/package.json b/package.json index cc42593f..89d96e30 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "test": "ng test", "deploy": "ng deploy", "version": "node -p \"require('./package.json').version\"", - "changelog": "conventional-changelog -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md" + "changelog": "conventional-changelog -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md", + "format": "prettier --write \"src/**/*.{ts,html,css,scss,json,js}\"" }, "dependencies": { "@angular-builders/custom-webpack": "^18.0.0", @@ -98,7 +99,7 @@ "karma-jasmine-html-reporter": "2.1.0", "lodash": "4.17.21", "postcss": "8.4.47", - "prettier": "3.3.3", + "prettier": "^3.3.3", "prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-tailwindcss": "0.6.8", "tailwindcss": "3.4.13", diff --git a/public/data/emoji.json b/public/data/emoji.json deleted file mode 100644 index 6aa7136a..00000000 --- a/public/data/emoji.json +++ /dev/null @@ -1,7612 +0,0 @@ -{ - "Smileys & Emotion": [ - { - "emoji": "๐Ÿ˜€", - "name": "grinning face" - }, - { - "emoji": "๐Ÿ˜ƒ", - "name": "grinning face with big eyes" - }, - { - "emoji": "๐Ÿ˜„", - "name": "grinning face with smiling eyes" - }, - { - "emoji": "๐Ÿ˜", - "name": "beaming face with smiling eyes" - }, - { - "emoji": "๐Ÿ˜†", - "name": "grinning squinting face" - }, - { - "emoji": "๐Ÿ˜…", - "name": "grinning face with sweat" - }, - { - "emoji": "๐Ÿคฃ", - "name": "rolling on the floor laughing" - }, - { - "emoji": "๐Ÿ˜‚", - "name": "face with tears of joy" - }, - { - "emoji": "๐Ÿ™‚", - "name": "slightly smiling face" - }, - { - "emoji": "๐Ÿ™ƒ", - "name": "upside-down face" - }, - { - "emoji": "๐Ÿซ ", - "name": "melting face" - }, - { - "emoji": "๐Ÿ˜‰", - "name": "winking face" - }, - { - "emoji": "๐Ÿ˜Š", - "name": "smiling face with smiling eyes" - }, - { - "emoji": "๐Ÿ˜‡", - "name": "smiling face with halo" - }, - { - "emoji": "๐Ÿฅฐ", - "name": "smiling face with hearts" - }, - { - "emoji": "๐Ÿ˜", - "name": "smiling face with heart-eyes" - }, - { - "emoji": "๐Ÿคฉ", - "name": "star-struck" - }, - { - "emoji": "๐Ÿ˜˜", - "name": "face blowing a kiss" - }, - { - "emoji": "๐Ÿ˜—", - "name": "kissing face" - }, - { - "emoji": "โ˜บ๏ธ", - "name": "smiling face" - }, - { - "emoji": "๐Ÿ˜š", - "name": "kissing face with closed eyes" - }, - { - "emoji": "๐Ÿ˜™", - "name": "kissing face with smiling eyes" - }, - { - "emoji": "๐Ÿฅฒ", - "name": "smiling face with tear" - }, - { - "emoji": "๐Ÿ˜‹", - "name": "face savoring food" - }, - { - "emoji": "๐Ÿ˜›", - "name": "face with tongue" - }, - { - "emoji": "๐Ÿ˜œ", - "name": "winking face with tongue" - }, - { - "emoji": "๐Ÿคช", - "name": "zany face" - }, - { - "emoji": "๐Ÿ˜", - "name": "squinting face with tongue" - }, - { - "emoji": "๐Ÿค‘", - "name": "money-mouth face" - }, - { - "emoji": "๐Ÿค—", - "name": "smiling face with open hands" - }, - { - "emoji": "๐Ÿคญ", - "name": "face with hand over mouth" - }, - { - "emoji": "๐Ÿซข", - "name": "face with open eyes and hand over mouth" - }, - { - "emoji": "๐Ÿซฃ", - "name": "face with peeking eye" - }, - { - "emoji": "๐Ÿคซ", - "name": "shushing face" - }, - { - "emoji": "๐Ÿค”", - "name": "thinking face" - }, - { - "emoji": "๐Ÿซก", - "name": "saluting face" - }, - { - "emoji": "๐Ÿค", - "name": "zipper-mouth face" - }, - { - "emoji": "๐Ÿคจ", - "name": "face with raised eyebrow" - }, - { - "emoji": "๐Ÿ˜", - "name": "neutral face" - }, - { - "emoji": "๐Ÿ˜‘", - "name": "expressionless face" - }, - { - "emoji": "๐Ÿ˜ถ", - "name": "face without mouth" - }, - { - "emoji": "๐Ÿซฅ", - "name": "dotted line face" - }, - { - "emoji": "๐Ÿ˜ถโ€๐ŸŒซ๏ธ", - "name": "face in clouds" - }, - { - "emoji": "๐Ÿ˜", - "name": "smirking face" - }, - { - "emoji": "๐Ÿ˜’", - "name": "unamused face" - }, - { - "emoji": "๐Ÿ™„", - "name": "face with rolling eyes" - }, - { - "emoji": "๐Ÿ˜ฌ", - "name": "grimacing face" - }, - { - "emoji": "๐Ÿ˜ฎโ€๐Ÿ’จ", - "name": "face exhaling" - }, - { - "emoji": "๐Ÿคฅ", - "name": "lying face" - }, - { - "emoji": "๐Ÿซจ", - "name": "shaking face" - }, - { - "emoji": "๐Ÿ™‚โ€โ†”๏ธ", - "name": "head shaking horizontally" - }, - { - "emoji": "๐Ÿ™‚โ€โ†•๏ธ", - "name": "head shaking vertically" - }, - { - "emoji": "๐Ÿ˜Œ", - "name": "relieved face" - }, - { - "emoji": "๐Ÿ˜”", - "name": "pensive face" - }, - { - "emoji": "๐Ÿ˜ช", - "name": "sleepy face" - }, - { - "emoji": "๐Ÿคค", - "name": "drooling face" - }, - { - "emoji": "๐Ÿ˜ด", - "name": "sleeping face" - }, - { - "emoji": "๐Ÿ˜ท", - "name": "face with medical mask" - }, - { - "emoji": "๐Ÿค’", - "name": "face with thermometer" - }, - { - "emoji": "๐Ÿค•", - "name": "face with head-bandage" - }, - { - "emoji": "๐Ÿคข", - "name": "nauseated face" - }, - { - "emoji": "๐Ÿคฎ", - "name": "face vomiting" - }, - { - "emoji": "๐Ÿคง", - "name": "sneezing face" - }, - { - "emoji": "๐Ÿฅต", - "name": "hot face" - }, - { - "emoji": "๐Ÿฅถ", - "name": "cold face" - }, - { - "emoji": "๐Ÿฅด", - "name": "woozy face" - }, - { - "emoji": "๐Ÿ˜ต", - "name": "face with crossed-out eyes" - }, - { - "emoji": "๐Ÿ˜ตโ€๐Ÿ’ซ", - "name": "face with spiral eyes" - }, - { - "emoji": "๐Ÿคฏ", - "name": "exploding head" - }, - { - "emoji": "๐Ÿค ", - "name": "cowboy hat face" - }, - { - "emoji": "๐Ÿฅณ", - "name": "partying face" - }, - { - "emoji": "๐Ÿฅธ", - "name": "disguised face" - }, - { - "emoji": "๐Ÿ˜Ž", - "name": "smiling face with sunglasses" - }, - { - "emoji": "๐Ÿค“", - "name": "nerd face" - }, - { - "emoji": "๐Ÿง", - "name": "face with monocle" - }, - { - "emoji": "๐Ÿ˜•", - "name": "confused face" - }, - { - "emoji": "๐Ÿซค", - "name": "face with diagonal mouth" - }, - { - "emoji": "๐Ÿ˜Ÿ", - "name": "worried face" - }, - { - "emoji": "๐Ÿ™", - "name": "slightly frowning face" - }, - { - "emoji": "โ˜น๏ธ", - "name": "frowning face" - }, - { - "emoji": "๐Ÿ˜ฎ", - "name": "face with open mouth" - }, - { - "emoji": "๐Ÿ˜ฏ", - "name": "hushed face" - }, - { - "emoji": "๐Ÿ˜ฒ", - "name": "astonished face" - }, - { - "emoji": "๐Ÿ˜ณ", - "name": "flushed face" - }, - { - "emoji": "๐Ÿฅบ", - "name": "pleading face" - }, - { - "emoji": "๐Ÿฅน", - "name": "face holding back tears" - }, - { - "emoji": "๐Ÿ˜ฆ", - "name": "frowning face with open mouth" - }, - { - "emoji": "๐Ÿ˜ง", - "name": "anguished face" - }, - { - "emoji": "๐Ÿ˜จ", - "name": "fearful face" - }, - { - "emoji": "๐Ÿ˜ฐ", - "name": "anxious face with sweat" - }, - { - "emoji": "๐Ÿ˜ฅ", - "name": "sad but relieved face" - }, - { - "emoji": "๐Ÿ˜ข", - "name": "crying face" - }, - { - "emoji": "๐Ÿ˜ญ", - "name": "loudly crying face" - }, - { - "emoji": "๐Ÿ˜ฑ", - "name": "face screaming in fear" - }, - { - "emoji": "๐Ÿ˜–", - "name": "confounded face" - }, - { - "emoji": "๐Ÿ˜ฃ", - "name": "persevering face" - }, - { - "emoji": "๐Ÿ˜ž", - "name": "disappointed face" - }, - { - "emoji": "๐Ÿ˜“", - "name": "downcast face with sweat" - }, - { - "emoji": "๐Ÿ˜ฉ", - "name": "weary face" - }, - { - "emoji": "๐Ÿ˜ซ", - "name": "tired face" - }, - { - "emoji": "๐Ÿฅฑ", - "name": "yawning face" - }, - { - "emoji": "๐Ÿ˜ค", - "name": "face with steam from nose" - }, - { - "emoji": "๐Ÿ˜ก", - "name": "enraged face" - }, - { - "emoji": "๐Ÿ˜ ", - "name": "angry face" - }, - { - "emoji": "๐Ÿคฌ", - "name": "face with symbols on mouth" - }, - { - "emoji": "๐Ÿ˜ˆ", - "name": "smiling face with horns" - }, - { - "emoji": "๐Ÿ‘ฟ", - "name": "angry face with horns" - }, - { - "emoji": "๐Ÿ’€", - "name": "skull" - }, - { - "emoji": "โ˜ ๏ธ", - "name": "skull and crossbones" - }, - { - "emoji": "๐Ÿ’ฉ", - "name": "pile of poo" - }, - { - "emoji": "๐Ÿคก", - "name": "clown face" - }, - { - "emoji": "๐Ÿ‘น", - "name": "ogre" - }, - { - "emoji": "๐Ÿ‘บ", - "name": "goblin" - }, - { - "emoji": "๐Ÿ‘ป", - "name": "ghost" - }, - { - "emoji": "๐Ÿ‘ฝ", - "name": "alien" - }, - { - "emoji": "๐Ÿ‘พ", - "name": "alien monster" - }, - { - "emoji": "๐Ÿค–", - "name": "robot" - }, - { - "emoji": "๐Ÿ˜บ", - "name": "grinning cat" - }, - { - "emoji": "๐Ÿ˜ธ", - "name": "grinning cat with smiling eyes" - }, - { - "emoji": "๐Ÿ˜น", - "name": "cat with tears of joy" - }, - { - "emoji": "๐Ÿ˜ป", - "name": "smiling cat with heart-eyes" - }, - { - "emoji": "๐Ÿ˜ผ", - "name": "cat with wry smile" - }, - { - "emoji": "๐Ÿ˜ฝ", - "name": "kissing cat" - }, - { - "emoji": "๐Ÿ™€", - "name": "weary cat" - }, - { - "emoji": "๐Ÿ˜ฟ", - "name": "crying cat" - }, - { - "emoji": "๐Ÿ˜พ", - "name": "pouting cat" - }, - { - "emoji": "๐Ÿ™ˆ", - "name": "see-no-evil monkey" - }, - { - "emoji": "๐Ÿ™‰", - "name": "hear-no-evil monkey" - }, - { - "emoji": "๐Ÿ™Š", - "name": "speak-no-evil monkey" - }, - { - "emoji": "๐Ÿ’Œ", - "name": "love letter" - }, - { - "emoji": "๐Ÿ’˜", - "name": "heart with arrow" - }, - { - "emoji": "๐Ÿ’", - "name": "heart with ribbon" - }, - { - "emoji": "๐Ÿ’–", - "name": "sparkling heart" - }, - { - "emoji": "๐Ÿ’—", - "name": "growing heart" - }, - { - "emoji": "๐Ÿ’“", - "name": "beating heart" - }, - { - "emoji": "๐Ÿ’ž", - "name": "revolving hearts" - }, - { - "emoji": "๐Ÿ’•", - "name": "two hearts" - }, - { - "emoji": "๐Ÿ’Ÿ", - "name": "heart decoration" - }, - { - "emoji": "โฃ๏ธ", - "name": "heart exclamation" - }, - { - "emoji": "๐Ÿ’”", - "name": "broken heart" - }, - { - "emoji": "โค๏ธโ€๐Ÿ”ฅ", - "name": "heart on fire" - }, - { - "emoji": "โค๏ธโ€๐Ÿฉน", - "name": "mending heart" - }, - { - "emoji": "โค๏ธ", - "name": "red heart" - }, - { - "emoji": "๐Ÿฉท", - "name": "pink heart" - }, - { - "emoji": "๐Ÿงก", - "name": "orange heart" - }, - { - "emoji": "๐Ÿ’›", - "name": "yellow heart" - }, - { - "emoji": "๐Ÿ’š", - "name": "green heart" - }, - { - "emoji": "๐Ÿ’™", - "name": "blue heart" - }, - { - "emoji": "๐Ÿฉต", - "name": "light blue heart" - }, - { - "emoji": "๐Ÿ’œ", - "name": "purple heart" - }, - { - "emoji": "๐ŸคŽ", - "name": "brown heart" - }, - { - "emoji": "๐Ÿ–ค", - "name": "black heart" - }, - { - "emoji": "๐Ÿฉถ", - "name": "grey heart" - }, - { - "emoji": "๐Ÿค", - "name": "white heart" - }, - { - "emoji": "๐Ÿ’‹", - "name": "kiss mark" - }, - { - "emoji": "๐Ÿ’ฏ", - "name": "hundred points" - }, - { - "emoji": "๐Ÿ’ข", - "name": "anger symbol" - }, - { - "emoji": "๐Ÿ’ฅ", - "name": "collision" - }, - { - "emoji": "๐Ÿ’ซ", - "name": "dizzy" - }, - { - "emoji": "๐Ÿ’ฆ", - "name": "sweat droplets" - }, - { - "emoji": "๐Ÿ’จ", - "name": "dashing away" - }, - { - "emoji": "๐Ÿ•ณ๏ธ", - "name": "hole" - }, - { - "emoji": "๐Ÿ’ฌ", - "name": "speech balloon" - }, - { - "emoji": "๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ", - "name": "eye in speech bubble" - }, - { - "emoji": "๐Ÿ—จ๏ธ", - "name": "left speech bubble" - }, - { - "emoji": "๐Ÿ—ฏ๏ธ", - "name": "right anger bubble" - }, - { - "emoji": "๐Ÿ’ญ", - "name": "thought balloon" - }, - { - "emoji": "๐Ÿ’ค", - "name": "ZZZ" - } - ], - "People & Body": [ - { - "emoji": "๐Ÿ‘‹", - "name": "waving hand" - }, - { - "emoji": "๐Ÿคš", - "name": "raised back of hand" - }, - { - "emoji": "๐Ÿ–๏ธ", - "name": "hand with fingers splayed" - }, - { - "emoji": "โœ‹", - "name": "raised hand" - }, - { - "emoji": "๐Ÿ––", - "name": "vulcan salute" - }, - { - "emoji": "๐Ÿซฑ", - "name": "rightwards hand" - }, - { - "emoji": "๐Ÿซฒ", - "name": "leftwards hand" - }, - { - "emoji": "๐Ÿซณ", - "name": "palm down hand" - }, - { - "emoji": "๐Ÿซด", - "name": "palm up hand" - }, - { - "emoji": "๐Ÿซท", - "name": "leftwards pushing hand" - }, - { - "emoji": "๐Ÿซธ", - "name": "rightwards pushing hand" - }, - { - "emoji": "๐Ÿ‘Œ", - "name": "OK hand" - }, - { - "emoji": "๐ŸคŒ", - "name": "pinched fingers" - }, - { - "emoji": "๐Ÿค", - "name": "pinching hand" - }, - { - "emoji": "โœŒ๏ธ", - "name": "victory hand" - }, - { - "emoji": "๐Ÿคž", - "name": "crossed fingers" - }, - { - "emoji": "๐Ÿซฐ", - "name": "hand with index finger and thumb crossed" - }, - { - "emoji": "๐ŸคŸ", - "name": "love-you gesture" - }, - { - "emoji": "๐Ÿค˜", - "name": "sign of the horns" - }, - { - "emoji": "๐Ÿค™", - "name": "call me hand" - }, - { - "emoji": "๐Ÿ‘ˆ", - "name": "backhand index pointing left" - }, - { - "emoji": "๐Ÿ‘‰", - "name": "backhand index pointing right" - }, - { - "emoji": "๐Ÿ‘†", - "name": "backhand index pointing up" - }, - { - "emoji": "๐Ÿ–•", - "name": "middle finger" - }, - { - "emoji": "๐Ÿ‘‡", - "name": "backhand index pointing down" - }, - { - "emoji": "โ˜๏ธ", - "name": "index pointing up" - }, - { - "emoji": "๐Ÿซต", - "name": "index pointing at the viewer" - }, - { - "emoji": "๐Ÿ‘", - "name": "thumbs up" - }, - { - "emoji": "๐Ÿ‘Ž", - "name": "thumbs down" - }, - { - "emoji": "โœŠ", - "name": "raised fist" - }, - { - "emoji": "๐Ÿ‘Š", - "name": "oncoming fist" - }, - { - "emoji": "๐Ÿค›", - "name": "left-facing fist" - }, - { - "emoji": "๐Ÿคœ", - "name": "right-facing fist" - }, - { - "emoji": "๐Ÿ‘", - "name": "clapping hands" - }, - { - "emoji": "๐Ÿ™Œ", - "name": "raising hands" - }, - { - "emoji": "๐Ÿซถ", - "name": "heart hands" - }, - { - "emoji": "๐Ÿ‘", - "name": "open hands" - }, - { - "emoji": "๐Ÿคฒ", - "name": "palms up together" - }, - { - "emoji": "๐Ÿค", - "name": "handshake" - }, - { - "emoji": "๐Ÿ™", - "name": "folded hands" - }, - { - "emoji": "โœ๏ธ", - "name": "writing hand" - }, - { - "emoji": "๐Ÿ’…", - "name": "nail polish" - }, - { - "emoji": "๐Ÿคณ", - "name": "selfie" - }, - { - "emoji": "๐Ÿ’ช", - "name": "flexed biceps" - }, - { - "emoji": "๐Ÿฆพ", - "name": "mechanical arm" - }, - { - "emoji": "๐Ÿฆฟ", - "name": "mechanical leg" - }, - { - "emoji": "๐Ÿฆต", - "name": "leg" - }, - { - "emoji": "๐Ÿฆถ", - "name": "foot" - }, - { - "emoji": "๐Ÿ‘‚", - "name": "ear" - }, - { - "emoji": "๐Ÿฆป", - "name": "ear with hearing aid" - }, - { - "emoji": "๐Ÿ‘ƒ", - "name": "nose" - }, - { - "emoji": "๐Ÿง ", - "name": "brain" - }, - { - "emoji": "๐Ÿซ€", - "name": "anatomical heart" - }, - { - "emoji": "๐Ÿซ", - "name": "lungs" - }, - { - "emoji": "๐Ÿฆท", - "name": "tooth" - }, - { - "emoji": "๐Ÿฆด", - "name": "bone" - }, - { - "emoji": "๐Ÿ‘€", - "name": "eyes" - }, - { - "emoji": "๐Ÿ‘๏ธ", - "name": "eye" - }, - { - "emoji": "๐Ÿ‘…", - "name": "tongue" - }, - { - "emoji": "๐Ÿ‘„", - "name": "mouth" - }, - { - "emoji": "๐Ÿซฆ", - "name": "biting lip" - }, - { - "emoji": "๐Ÿ‘ถ", - "name": "baby" - }, - { - "emoji": "๐Ÿง’", - "name": "child" - }, - { - "emoji": "๐Ÿ‘ฆ", - "name": "boy" - }, - { - "emoji": "๐Ÿ‘ง", - "name": "girl" - }, - { - "emoji": "๐Ÿง‘", - "name": "person" - }, - { - "emoji": "๐Ÿ‘ฑ", - "name": "person blond hair" - }, - { - "emoji": "๐Ÿ‘จ", - "name": "man" - }, - { - "emoji": "๐Ÿง”", - "name": "person beard" - }, - { - "emoji": "๐Ÿง”โ€โ™‚๏ธ", - "name": "man beard" - }, - { - "emoji": "๐Ÿง”โ€โ™€๏ธ", - "name": "woman beard" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฐ", - "name": "man red hair" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฑ", - "name": "man curly hair" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆณ", - "name": "man white hair" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฒ", - "name": "man bald" - }, - { - "emoji": "๐Ÿ‘ฉ", - "name": "woman" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฐ", - "name": "woman red hair" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฐ", - "name": "person red hair" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฑ", - "name": "woman curly hair" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฑ", - "name": "person curly hair" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆณ", - "name": "woman white hair" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆณ", - "name": "person white hair" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฒ", - "name": "woman bald" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฒ", - "name": "person bald" - }, - { - "emoji": "๐Ÿ‘ฑโ€โ™€๏ธ", - "name": "woman blond hair" - }, - { - "emoji": "๐Ÿ‘ฑโ€โ™‚๏ธ", - "name": "man blond hair" - }, - { - "emoji": "๐Ÿง“", - "name": "older person" - }, - { - "emoji": "๐Ÿ‘ด", - "name": "old man" - }, - { - "emoji": "๐Ÿ‘ต", - "name": "old woman" - }, - { - "emoji": "๐Ÿ™", - "name": "person frowning" - }, - { - "emoji": "๐Ÿ™โ€โ™‚๏ธ", - "name": "man frowning" - }, - { - "emoji": "๐Ÿ™โ€โ™€๏ธ", - "name": "woman frowning" - }, - { - "emoji": "๐Ÿ™Ž", - "name": "person pouting" - }, - { - "emoji": "๐Ÿ™Žโ€โ™‚๏ธ", - "name": "man pouting" - }, - { - "emoji": "๐Ÿ™Žโ€โ™€๏ธ", - "name": "woman pouting" - }, - { - "emoji": "๐Ÿ™…", - "name": "person gesturing NO" - }, - { - "emoji": "๐Ÿ™…โ€โ™‚๏ธ", - "name": "man gesturing NO" - }, - { - "emoji": "๐Ÿ™…โ€โ™€๏ธ", - "name": "woman gesturing NO" - }, - { - "emoji": "๐Ÿ™†", - "name": "person gesturing OK" - }, - { - "emoji": "๐Ÿ™†โ€โ™‚๏ธ", - "name": "man gesturing OK" - }, - { - "emoji": "๐Ÿ™†โ€โ™€๏ธ", - "name": "woman gesturing OK" - }, - { - "emoji": "๐Ÿ’", - "name": "person tipping hand" - }, - { - "emoji": "๐Ÿ’โ€โ™‚๏ธ", - "name": "man tipping hand" - }, - { - "emoji": "๐Ÿ’โ€โ™€๏ธ", - "name": "woman tipping hand" - }, - { - "emoji": "๐Ÿ™‹", - "name": "person raising hand" - }, - { - "emoji": "๐Ÿ™‹โ€โ™‚๏ธ", - "name": "man raising hand" - }, - { - "emoji": "๐Ÿ™‹โ€โ™€๏ธ", - "name": "woman raising hand" - }, - { - "emoji": "๐Ÿง", - "name": "deaf person" - }, - { - "emoji": "๐Ÿงโ€โ™‚๏ธ", - "name": "deaf man" - }, - { - "emoji": "๐Ÿงโ€โ™€๏ธ", - "name": "deaf woman" - }, - { - "emoji": "๐Ÿ™‡", - "name": "person bowing" - }, - { - "emoji": "๐Ÿ™‡โ€โ™‚๏ธ", - "name": "man bowing" - }, - { - "emoji": "๐Ÿ™‡โ€โ™€๏ธ", - "name": "woman bowing" - }, - { - "emoji": "๐Ÿคฆ", - "name": "person facepalming" - }, - { - "emoji": "๐Ÿคฆโ€โ™‚๏ธ", - "name": "man facepalming" - }, - { - "emoji": "๐Ÿคฆโ€โ™€๏ธ", - "name": "woman facepalming" - }, - { - "emoji": "๐Ÿคท", - "name": "person shrugging" - }, - { - "emoji": "๐Ÿคทโ€โ™‚๏ธ", - "name": "man shrugging" - }, - { - "emoji": "๐Ÿคทโ€โ™€๏ธ", - "name": "woman shrugging" - }, - { - "emoji": "๐Ÿง‘โ€โš•๏ธ", - "name": "health worker" - }, - { - "emoji": "๐Ÿ‘จโ€โš•๏ธ", - "name": "man health worker" - }, - { - "emoji": "๐Ÿ‘ฉโ€โš•๏ธ", - "name": "woman health worker" - }, - { - "emoji": "๐Ÿง‘โ€๐ŸŽ“", - "name": "student" - }, - { - "emoji": "๐Ÿ‘จโ€๐ŸŽ“", - "name": "man student" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐ŸŽ“", - "name": "woman student" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿซ", - "name": "teacher" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿซ", - "name": "man teacher" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿซ", - "name": "woman teacher" - }, - { - "emoji": "๐Ÿง‘โ€โš–๏ธ", - "name": "judge" - }, - { - "emoji": "๐Ÿ‘จโ€โš–๏ธ", - "name": "man judge" - }, - { - "emoji": "๐Ÿ‘ฉโ€โš–๏ธ", - "name": "woman judge" - }, - { - "emoji": "๐Ÿง‘โ€๐ŸŒพ", - "name": "farmer" - }, - { - "emoji": "๐Ÿ‘จโ€๐ŸŒพ", - "name": "man farmer" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐ŸŒพ", - "name": "woman farmer" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿณ", - "name": "cook" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿณ", - "name": "man cook" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿณ", - "name": "woman cook" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿ”ง", - "name": "mechanic" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ”ง", - "name": "man mechanic" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ”ง", - "name": "woman mechanic" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿญ", - "name": "factory worker" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿญ", - "name": "man factory worker" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿญ", - "name": "woman factory worker" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿ’ผ", - "name": "office worker" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ’ผ", - "name": "man office worker" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ’ผ", - "name": "woman office worker" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿ”ฌ", - "name": "scientist" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ”ฌ", - "name": "man scientist" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ”ฌ", - "name": "woman scientist" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿ’ป", - "name": "technologist" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ’ป", - "name": "man technologist" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ’ป", - "name": "woman technologist" - }, - { - "emoji": "๐Ÿง‘โ€๐ŸŽค", - "name": "singer" - }, - { - "emoji": "๐Ÿ‘จโ€๐ŸŽค", - "name": "man singer" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐ŸŽค", - "name": "woman singer" - }, - { - "emoji": "๐Ÿง‘โ€๐ŸŽจ", - "name": "artist" - }, - { - "emoji": "๐Ÿ‘จโ€๐ŸŽจ", - "name": "man artist" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐ŸŽจ", - "name": "woman artist" - }, - { - "emoji": "๐Ÿง‘โ€โœˆ๏ธ", - "name": "pilot" - }, - { - "emoji": "๐Ÿ‘จโ€โœˆ๏ธ", - "name": "man pilot" - }, - { - "emoji": "๐Ÿ‘ฉโ€โœˆ๏ธ", - "name": "woman pilot" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿš€", - "name": "astronaut" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿš€", - "name": "man astronaut" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿš€", - "name": "woman astronaut" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿš’", - "name": "firefighter" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿš’", - "name": "man firefighter" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿš’", - "name": "woman firefighter" - }, - { - "emoji": "๐Ÿ‘ฎ", - "name": "police officer" - }, - { - "emoji": "๐Ÿ‘ฎโ€โ™‚๏ธ", - "name": "man police officer" - }, - { - "emoji": "๐Ÿ‘ฎโ€โ™€๏ธ", - "name": "woman police officer" - }, - { - "emoji": "๐Ÿ•ต๏ธ", - "name": "detective" - }, - { - "emoji": "๐Ÿ•ต๏ธโ€โ™‚๏ธ", - "name": "man detective" - }, - { - "emoji": "๐Ÿ•ต๏ธโ€โ™€๏ธ", - "name": "woman detective" - }, - { - "emoji": "๐Ÿ’‚", - "name": "guard" - }, - { - "emoji": "๐Ÿ’‚โ€โ™‚๏ธ", - "name": "man guard" - }, - { - "emoji": "๐Ÿ’‚โ€โ™€๏ธ", - "name": "woman guard" - }, - { - "emoji": "๐Ÿฅท", - "name": "ninja" - }, - { - "emoji": "๐Ÿ‘ท", - "name": "construction worker" - }, - { - "emoji": "๐Ÿ‘ทโ€โ™‚๏ธ", - "name": "man construction worker" - }, - { - "emoji": "๐Ÿ‘ทโ€โ™€๏ธ", - "name": "woman construction worker" - }, - { - "emoji": "๐Ÿซ…", - "name": "person with crown" - }, - { - "emoji": "๐Ÿคด", - "name": "prince" - }, - { - "emoji": "๐Ÿ‘ธ", - "name": "princess" - }, - { - "emoji": "๐Ÿ‘ณ", - "name": "person wearing turban" - }, - { - "emoji": "๐Ÿ‘ณโ€โ™‚๏ธ", - "name": "man wearing turban" - }, - { - "emoji": "๐Ÿ‘ณโ€โ™€๏ธ", - "name": "woman wearing turban" - }, - { - "emoji": "๐Ÿ‘ฒ", - "name": "person with skullcap" - }, - { - "emoji": "๐Ÿง•", - "name": "woman with headscarf" - }, - { - "emoji": "๐Ÿคต", - "name": "person in tuxedo" - }, - { - "emoji": "๐Ÿคตโ€โ™‚๏ธ", - "name": "man in tuxedo" - }, - { - "emoji": "๐Ÿคตโ€โ™€๏ธ", - "name": "woman in tuxedo" - }, - { - "emoji": "๐Ÿ‘ฐ", - "name": "person with veil" - }, - { - "emoji": "๐Ÿ‘ฐโ€โ™‚๏ธ", - "name": "man with veil" - }, - { - "emoji": "๐Ÿ‘ฐโ€โ™€๏ธ", - "name": "woman with veil" - }, - { - "emoji": "๐Ÿคฐ", - "name": "pregnant woman" - }, - { - "emoji": "๐Ÿซƒ", - "name": "pregnant man" - }, - { - "emoji": "๐Ÿซ„", - "name": "pregnant person" - }, - { - "emoji": "๐Ÿคฑ", - "name": "breast-feeding" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿผ", - "name": "woman feeding baby" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿผ", - "name": "man feeding baby" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿผ", - "name": "person feeding baby" - }, - { - "emoji": "๐Ÿ‘ผ", - "name": "baby angel" - }, - { - "emoji": "๐ŸŽ…", - "name": "Santa Claus" - }, - { - "emoji": "๐Ÿคถ", - "name": "Mrs. Claus" - }, - { - "emoji": "๐Ÿง‘โ€๐ŸŽ„", - "name": "mx claus" - }, - { - "emoji": "๐Ÿฆธ", - "name": "superhero" - }, - { - "emoji": "๐Ÿฆธโ€โ™‚๏ธ", - "name": "man superhero" - }, - { - "emoji": "๐Ÿฆธโ€โ™€๏ธ", - "name": "woman superhero" - }, - { - "emoji": "๐Ÿฆน", - "name": "supervillain" - }, - { - "emoji": "๐Ÿฆนโ€โ™‚๏ธ", - "name": "man supervillain" - }, - { - "emoji": "๐Ÿฆนโ€โ™€๏ธ", - "name": "woman supervillain" - }, - { - "emoji": "๐Ÿง™", - "name": "mage" - }, - { - "emoji": "๐Ÿง™โ€โ™‚๏ธ", - "name": "man mage" - }, - { - "emoji": "๐Ÿง™โ€โ™€๏ธ", - "name": "woman mage" - }, - { - "emoji": "๐Ÿงš", - "name": "fairy" - }, - { - "emoji": "๐Ÿงšโ€โ™‚๏ธ", - "name": "man fairy" - }, - { - "emoji": "๐Ÿงšโ€โ™€๏ธ", - "name": "woman fairy" - }, - { - "emoji": "๐Ÿง›", - "name": "vampire" - }, - { - "emoji": "๐Ÿง›โ€โ™‚๏ธ", - "name": "man vampire" - }, - { - "emoji": "๐Ÿง›โ€โ™€๏ธ", - "name": "woman vampire" - }, - { - "emoji": "๐Ÿงœ", - "name": "merperson" - }, - { - "emoji": "๐Ÿงœโ€โ™‚๏ธ", - "name": "merman" - }, - { - "emoji": "๐Ÿงœโ€โ™€๏ธ", - "name": "mermaid" - }, - { - "emoji": "๐Ÿง", - "name": "elf" - }, - { - "emoji": "๐Ÿงโ€โ™‚๏ธ", - "name": "man elf" - }, - { - "emoji": "๐Ÿงโ€โ™€๏ธ", - "name": "woman elf" - }, - { - "emoji": "๐Ÿงž", - "name": "genie" - }, - { - "emoji": "๐Ÿงžโ€โ™‚๏ธ", - "name": "man genie" - }, - { - "emoji": "๐Ÿงžโ€โ™€๏ธ", - "name": "woman genie" - }, - { - "emoji": "๐ŸงŸ", - "name": "zombie" - }, - { - "emoji": "๐ŸงŸโ€โ™‚๏ธ", - "name": "man zombie" - }, - { - "emoji": "๐ŸงŸโ€โ™€๏ธ", - "name": "woman zombie" - }, - { - "emoji": "๐ŸงŒ", - "name": "troll" - }, - { - "emoji": "๐Ÿ’†", - "name": "person getting massage" - }, - { - "emoji": "๐Ÿ’†โ€โ™‚๏ธ", - "name": "man getting massage" - }, - { - "emoji": "๐Ÿ’†โ€โ™€๏ธ", - "name": "woman getting massage" - }, - { - "emoji": "๐Ÿ’‡", - "name": "person getting haircut" - }, - { - "emoji": "๐Ÿ’‡โ€โ™‚๏ธ", - "name": "man getting haircut" - }, - { - "emoji": "๐Ÿ’‡โ€โ™€๏ธ", - "name": "woman getting haircut" - }, - { - "emoji": "๐Ÿšถ", - "name": "person walking" - }, - { - "emoji": "๐Ÿšถโ€โ™‚๏ธ", - "name": "man walking" - }, - { - "emoji": "๐Ÿšถโ€โ™€๏ธ", - "name": "woman walking" - }, - { - "emoji": "๐Ÿšถโ€โžก๏ธ", - "name": "person walking facing right" - }, - { - "emoji": "๐Ÿšถโ€โ™€๏ธโ€โžก๏ธ", - "name": "woman walking facing right" - }, - { - "emoji": "๐Ÿšถโ€โ™‚๏ธโ€โžก๏ธ", - "name": "man walking facing right" - }, - { - "emoji": "๐Ÿง", - "name": "person standing" - }, - { - "emoji": "๐Ÿงโ€โ™‚๏ธ", - "name": "man standing" - }, - { - "emoji": "๐Ÿงโ€โ™€๏ธ", - "name": "woman standing" - }, - { - "emoji": "๐ŸงŽ", - "name": "person kneeling" - }, - { - "emoji": "๐ŸงŽโ€โ™‚๏ธ", - "name": "man kneeling" - }, - { - "emoji": "๐ŸงŽโ€โ™€๏ธ", - "name": "woman kneeling" - }, - { - "emoji": "๐ŸงŽโ€โžก๏ธ", - "name": "person kneeling facing right" - }, - { - "emoji": "๐ŸงŽโ€โ™€๏ธโ€โžก๏ธ", - "name": "woman kneeling facing right" - }, - { - "emoji": "๐ŸงŽโ€โ™‚๏ธโ€โžก๏ธ", - "name": "man kneeling facing right" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฏ", - "name": "person with white cane" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฏโ€โžก๏ธ", - "name": "person with white cane facing right" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฏ", - "name": "man with white cane" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฏโ€โžก๏ธ", - "name": "man with white cane facing right" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฏ", - "name": "woman with white cane" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฏโ€โžก๏ธ", - "name": "woman with white cane facing right" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆผ", - "name": "person in motorized wheelchair" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆผโ€โžก๏ธ", - "name": "person in motorized wheelchair facing right" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆผ", - "name": "man in motorized wheelchair" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆผโ€โžก๏ธ", - "name": "man in motorized wheelchair facing right" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆผ", - "name": "woman in motorized wheelchair" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆผโ€โžก๏ธ", - "name": "woman in motorized wheelchair facing right" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฝ", - "name": "person in manual wheelchair" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿฆฝโ€โžก๏ธ", - "name": "person in manual wheelchair facing right" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฝ", - "name": "man in manual wheelchair" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿฆฝโ€โžก๏ธ", - "name": "man in manual wheelchair facing right" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฝ", - "name": "woman in manual wheelchair" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿฆฝโ€โžก๏ธ", - "name": "woman in manual wheelchair facing right" - }, - { - "emoji": "๐Ÿƒ", - "name": "person running" - }, - { - "emoji": "๐Ÿƒโ€โ™‚๏ธ", - "name": "man running" - }, - { - "emoji": "๐Ÿƒโ€โ™€๏ธ", - "name": "woman running" - }, - { - "emoji": "๐Ÿƒโ€โžก๏ธ", - "name": "person running facing right" - }, - { - "emoji": "๐Ÿƒโ€โ™€๏ธโ€โžก๏ธ", - "name": "woman running facing right" - }, - { - "emoji": "๐Ÿƒโ€โ™‚๏ธโ€โžก๏ธ", - "name": "man running facing right" - }, - { - "emoji": "๐Ÿ’ƒ", - "name": "woman dancing" - }, - { - "emoji": "๐Ÿ•บ", - "name": "man dancing" - }, - { - "emoji": "๐Ÿ•ด๏ธ", - "name": "person in suit levitating" - }, - { - "emoji": "๐Ÿ‘ฏ", - "name": "people with bunny ears" - }, - { - "emoji": "๐Ÿ‘ฏโ€โ™‚๏ธ", - "name": "men with bunny ears" - }, - { - "emoji": "๐Ÿ‘ฏโ€โ™€๏ธ", - "name": "women with bunny ears" - }, - { - "emoji": "๐Ÿง–", - "name": "person in steamy room" - }, - { - "emoji": "๐Ÿง–โ€โ™‚๏ธ", - "name": "man in steamy room" - }, - { - "emoji": "๐Ÿง–โ€โ™€๏ธ", - "name": "woman in steamy room" - }, - { - "emoji": "๐Ÿง—", - "name": "person climbing" - }, - { - "emoji": "๐Ÿง—โ€โ™‚๏ธ", - "name": "man climbing" - }, - { - "emoji": "๐Ÿง—โ€โ™€๏ธ", - "name": "woman climbing" - }, - { - "emoji": "๐Ÿคบ", - "name": "person fencing" - }, - { - "emoji": "๐Ÿ‡", - "name": "horse racing" - }, - { - "emoji": "โ›ท๏ธ", - "name": "skier" - }, - { - "emoji": "๐Ÿ‚", - "name": "snowboarder" - }, - { - "emoji": "๐ŸŒ๏ธ", - "name": "person golfing" - }, - { - "emoji": "๐ŸŒ๏ธโ€โ™‚๏ธ", - "name": "man golfing" - }, - { - "emoji": "๐ŸŒ๏ธโ€โ™€๏ธ", - "name": "woman golfing" - }, - { - "emoji": "๐Ÿ„", - "name": "person surfing" - }, - { - "emoji": "๐Ÿ„โ€โ™‚๏ธ", - "name": "man surfing" - }, - { - "emoji": "๐Ÿ„โ€โ™€๏ธ", - "name": "woman surfing" - }, - { - "emoji": "๐Ÿšฃ", - "name": "person rowing boat" - }, - { - "emoji": "๐Ÿšฃโ€โ™‚๏ธ", - "name": "man rowing boat" - }, - { - "emoji": "๐Ÿšฃโ€โ™€๏ธ", - "name": "woman rowing boat" - }, - { - "emoji": "๐ŸŠ", - "name": "person swimming" - }, - { - "emoji": "๐ŸŠโ€โ™‚๏ธ", - "name": "man swimming" - }, - { - "emoji": "๐ŸŠโ€โ™€๏ธ", - "name": "woman swimming" - }, - { - "emoji": "โ›น๏ธ", - "name": "person bouncing ball" - }, - { - "emoji": "โ›น๏ธโ€โ™‚๏ธ", - "name": "man bouncing ball" - }, - { - "emoji": "โ›น๏ธโ€โ™€๏ธ", - "name": "woman bouncing ball" - }, - { - "emoji": "๐Ÿ‹๏ธ", - "name": "person lifting weights" - }, - { - "emoji": "๐Ÿ‹๏ธโ€โ™‚๏ธ", - "name": "man lifting weights" - }, - { - "emoji": "๐Ÿ‹๏ธโ€โ™€๏ธ", - "name": "woman lifting weights" - }, - { - "emoji": "๐Ÿšด", - "name": "person biking" - }, - { - "emoji": "๐Ÿšดโ€โ™‚๏ธ", - "name": "man biking" - }, - { - "emoji": "๐Ÿšดโ€โ™€๏ธ", - "name": "woman biking" - }, - { - "emoji": "๐Ÿšต", - "name": "person mountain biking" - }, - { - "emoji": "๐Ÿšตโ€โ™‚๏ธ", - "name": "man mountain biking" - }, - { - "emoji": "๐Ÿšตโ€โ™€๏ธ", - "name": "woman mountain biking" - }, - { - "emoji": "๐Ÿคธ", - "name": "person cartwheeling" - }, - { - "emoji": "๐Ÿคธโ€โ™‚๏ธ", - "name": "man cartwheeling" - }, - { - "emoji": "๐Ÿคธโ€โ™€๏ธ", - "name": "woman cartwheeling" - }, - { - "emoji": "๐Ÿคผ", - "name": "people wrestling" - }, - { - "emoji": "๐Ÿคผโ€โ™‚๏ธ", - "name": "men wrestling" - }, - { - "emoji": "๐Ÿคผโ€โ™€๏ธ", - "name": "women wrestling" - }, - { - "emoji": "๐Ÿคฝ", - "name": "person playing water polo" - }, - { - "emoji": "๐Ÿคฝโ€โ™‚๏ธ", - "name": "man playing water polo" - }, - { - "emoji": "๐Ÿคฝโ€โ™€๏ธ", - "name": "woman playing water polo" - }, - { - "emoji": "๐Ÿคพ", - "name": "person playing handball" - }, - { - "emoji": "๐Ÿคพโ€โ™‚๏ธ", - "name": "man playing handball" - }, - { - "emoji": "๐Ÿคพโ€โ™€๏ธ", - "name": "woman playing handball" - }, - { - "emoji": "๐Ÿคน", - "name": "person juggling" - }, - { - "emoji": "๐Ÿคนโ€โ™‚๏ธ", - "name": "man juggling" - }, - { - "emoji": "๐Ÿคนโ€โ™€๏ธ", - "name": "woman juggling" - }, - { - "emoji": "๐Ÿง˜", - "name": "person in lotus position" - }, - { - "emoji": "๐Ÿง˜โ€โ™‚๏ธ", - "name": "man in lotus position" - }, - { - "emoji": "๐Ÿง˜โ€โ™€๏ธ", - "name": "woman in lotus position" - }, - { - "emoji": "๐Ÿ›€", - "name": "person taking bath" - }, - { - "emoji": "๐Ÿ›Œ", - "name": "person in bed" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘", - "name": "people holding hands" - }, - { - "emoji": "๐Ÿ‘ญ", - "name": "women holding hands" - }, - { - "emoji": "๐Ÿ‘ซ", - "name": "woman and man holding hands" - }, - { - "emoji": "๐Ÿ‘ฌ", - "name": "men holding hands" - }, - { - "emoji": "๐Ÿ’", - "name": "kiss" - }, - { - "emoji": "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ", - "name": "kiss woman, man" - }, - { - "emoji": "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ", - "name": "kiss man, man" - }, - { - "emoji": "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ", - "name": "kiss woman, woman" - }, - { - "emoji": "๐Ÿ’‘", - "name": "couple with heart" - }, - { - "emoji": "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘จ", - "name": "couple with heart woman, man" - }, - { - "emoji": "๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ", - "name": "couple with heart man, man" - }, - { - "emoji": "๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ", - "name": "couple with heart woman, woman" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ", - "name": "family man, woman, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", - "name": "family man, woman, girl" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", - "name": "family man, woman, girl, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ", - "name": "family man, woman, boy, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง", - "name": "family man, woman, girl, girl" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ", - "name": "family man, man, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง", - "name": "family man, man, girl" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", - "name": "family man, man, girl, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ", - "name": "family man, man, boy, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง", - "name": "family man, man, girl, girl" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ", - "name": "family woman, woman, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง", - "name": "family woman, woman, girl" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", - "name": "family woman, woman, girl, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ", - "name": "family woman, woman, boy, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง", - "name": "family woman, woman, girl, girl" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฆ", - "name": "family man, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ", - "name": "family man, boy, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘ง", - "name": "family man, girl" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", - "name": "family man, girl, boy" - }, - { - "emoji": "๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง", - "name": "family man, girl, girl" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฆ", - "name": "family woman, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ", - "name": "family woman, boy, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘ง", - "name": "family woman, girl" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", - "name": "family woman, girl, boy" - }, - { - "emoji": "๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง", - "name": "family woman, girl, girl" - }, - { - "emoji": "๐Ÿ—ฃ๏ธ", - "name": "speaking head" - }, - { - "emoji": "๐Ÿ‘ค", - "name": "bust in silhouette" - }, - { - "emoji": "๐Ÿ‘ฅ", - "name": "busts in silhouette" - }, - { - "emoji": "๐Ÿซ‚", - "name": "people hugging" - }, - { - "emoji": "๐Ÿ‘ช", - "name": "family" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’", - "name": "family adult, adult, child" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’", - "name": "family adult, adult, child, child" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿง’", - "name": "family adult, child" - }, - { - "emoji": "๐Ÿง‘โ€๐Ÿง’โ€๐Ÿง’", - "name": "family adult, child, child" - }, - { - "emoji": "๐Ÿ‘ฃ", - "name": "footprints" - } - ], - "Animals & Nature": [ - { - "emoji": "๐Ÿต", - "name": "monkey face" - }, - { - "emoji": "๐Ÿ’", - "name": "monkey" - }, - { - "emoji": "๐Ÿฆ", - "name": "gorilla" - }, - { - "emoji": "๐Ÿฆง", - "name": "orangutan" - }, - { - "emoji": "๐Ÿถ", - "name": "dog face" - }, - { - "emoji": "๐Ÿ•", - "name": "dog" - }, - { - "emoji": "๐Ÿฆฎ", - "name": "guide dog" - }, - { - "emoji": "๐Ÿ•โ€๐Ÿฆบ", - "name": "service dog" - }, - { - "emoji": "๐Ÿฉ", - "name": "poodle" - }, - { - "emoji": "๐Ÿบ", - "name": "wolf" - }, - { - "emoji": "๐ŸฆŠ", - "name": "fox" - }, - { - "emoji": "๐Ÿฆ", - "name": "raccoon" - }, - { - "emoji": "๐Ÿฑ", - "name": "cat face" - }, - { - "emoji": "๐Ÿˆ", - "name": "cat" - }, - { - "emoji": "๐Ÿˆโ€โฌ›", - "name": "black cat" - }, - { - "emoji": "๐Ÿฆ", - "name": "lion" - }, - { - "emoji": "๐Ÿฏ", - "name": "tiger face" - }, - { - "emoji": "๐Ÿ…", - "name": "tiger" - }, - { - "emoji": "๐Ÿ†", - "name": "leopard" - }, - { - "emoji": "๐Ÿด", - "name": "horse face" - }, - { - "emoji": "๐ŸซŽ", - "name": "moose" - }, - { - "emoji": "๐Ÿซ", - "name": "donkey" - }, - { - "emoji": "๐ŸŽ", - "name": "horse" - }, - { - "emoji": "๐Ÿฆ„", - "name": "unicorn" - }, - { - "emoji": "๐Ÿฆ“", - "name": "zebra" - }, - { - "emoji": "๐ŸฆŒ", - "name": "deer" - }, - { - "emoji": "๐Ÿฆฌ", - "name": "bison" - }, - { - "emoji": "๐Ÿฎ", - "name": "cow face" - }, - { - "emoji": "๐Ÿ‚", - "name": "ox" - }, - { - "emoji": "๐Ÿƒ", - "name": "water buffalo" - }, - { - "emoji": "๐Ÿ„", - "name": "cow" - }, - { - "emoji": "๐Ÿท", - "name": "pig face" - }, - { - "emoji": "๐Ÿ–", - "name": "pig" - }, - { - "emoji": "๐Ÿ—", - "name": "boar" - }, - { - "emoji": "๐Ÿฝ", - "name": "pig nose" - }, - { - "emoji": "๐Ÿ", - "name": "ram" - }, - { - "emoji": "๐Ÿ‘", - "name": "ewe" - }, - { - "emoji": "๐Ÿ", - "name": "goat" - }, - { - "emoji": "๐Ÿช", - "name": "camel" - }, - { - "emoji": "๐Ÿซ", - "name": "two-hump camel" - }, - { - "emoji": "๐Ÿฆ™", - "name": "llama" - }, - { - "emoji": "๐Ÿฆ’", - "name": "giraffe" - }, - { - "emoji": "๐Ÿ˜", - "name": "elephant" - }, - { - "emoji": "๐Ÿฆฃ", - "name": "mammoth" - }, - { - "emoji": "๐Ÿฆ", - "name": "rhinoceros" - }, - { - "emoji": "๐Ÿฆ›", - "name": "hippopotamus" - }, - { - "emoji": "๐Ÿญ", - "name": "mouse face" - }, - { - "emoji": "๐Ÿ", - "name": "mouse" - }, - { - "emoji": "๐Ÿ€", - "name": "rat" - }, - { - "emoji": "๐Ÿน", - "name": "hamster" - }, - { - "emoji": "๐Ÿฐ", - "name": "rabbit face" - }, - { - "emoji": "๐Ÿ‡", - "name": "rabbit" - }, - { - "emoji": "๐Ÿฟ๏ธ", - "name": "chipmunk" - }, - { - "emoji": "๐Ÿฆซ", - "name": "beaver" - }, - { - "emoji": "๐Ÿฆ”", - "name": "hedgehog" - }, - { - "emoji": "๐Ÿฆ‡", - "name": "bat" - }, - { - "emoji": "๐Ÿป", - "name": "bear" - }, - { - "emoji": "๐Ÿปโ€โ„๏ธ", - "name": "polar bear" - }, - { - "emoji": "๐Ÿจ", - "name": "koala" - }, - { - "emoji": "๐Ÿผ", - "name": "panda" - }, - { - "emoji": "๐Ÿฆฅ", - "name": "sloth" - }, - { - "emoji": "๐Ÿฆฆ", - "name": "otter" - }, - { - "emoji": "๐Ÿฆจ", - "name": "skunk" - }, - { - "emoji": "๐Ÿฆ˜", - "name": "kangaroo" - }, - { - "emoji": "๐Ÿฆก", - "name": "badger" - }, - { - "emoji": "๐Ÿพ", - "name": "paw prints" - }, - { - "emoji": "๐Ÿฆƒ", - "name": "turkey" - }, - { - "emoji": "๐Ÿ”", - "name": "chicken" - }, - { - "emoji": "๐Ÿ“", - "name": "rooster" - }, - { - "emoji": "๐Ÿฃ", - "name": "hatching chick" - }, - { - "emoji": "๐Ÿค", - "name": "baby chick" - }, - { - "emoji": "๐Ÿฅ", - "name": "front-facing baby chick" - }, - { - "emoji": "๐Ÿฆ", - "name": "bird" - }, - { - "emoji": "๐Ÿง", - "name": "penguin" - }, - { - "emoji": "๐Ÿ•Š๏ธ", - "name": "dove" - }, - { - "emoji": "๐Ÿฆ…", - "name": "eagle" - }, - { - "emoji": "๐Ÿฆ†", - "name": "duck" - }, - { - "emoji": "๐Ÿฆข", - "name": "swan" - }, - { - "emoji": "๐Ÿฆ‰", - "name": "owl" - }, - { - "emoji": "๐Ÿฆค", - "name": "dodo" - }, - { - "emoji": "๐Ÿชถ", - "name": "feather" - }, - { - "emoji": "๐Ÿฆฉ", - "name": "flamingo" - }, - { - "emoji": "๐Ÿฆš", - "name": "peacock" - }, - { - "emoji": "๐Ÿฆœ", - "name": "parrot" - }, - { - "emoji": "๐Ÿชฝ", - "name": "wing" - }, - { - "emoji": "๐Ÿฆโ€โฌ›", - "name": "black bird" - }, - { - "emoji": "๐Ÿชฟ", - "name": "goose" - }, - { - "emoji": "๐Ÿฆโ€๐Ÿ”ฅ", - "name": "phoenix" - }, - { - "emoji": "๐Ÿธ", - "name": "frog" - }, - { - "emoji": "๐ŸŠ", - "name": "crocodile" - }, - { - "emoji": "๐Ÿข", - "name": "turtle" - }, - { - "emoji": "๐ŸฆŽ", - "name": "lizard" - }, - { - "emoji": "๐Ÿ", - "name": "snake" - }, - { - "emoji": "๐Ÿฒ", - "name": "dragon face" - }, - { - "emoji": "๐Ÿ‰", - "name": "dragon" - }, - { - "emoji": "๐Ÿฆ•", - "name": "sauropod" - }, - { - "emoji": "๐Ÿฆ–", - "name": "T-Rex" - }, - { - "emoji": "๐Ÿณ", - "name": "spouting whale" - }, - { - "emoji": "๐Ÿ‹", - "name": "whale" - }, - { - "emoji": "๐Ÿฌ", - "name": "dolphin" - }, - { - "emoji": "๐Ÿฆญ", - "name": "seal" - }, - { - "emoji": "๐ŸŸ", - "name": "fish" - }, - { - "emoji": "๐Ÿ ", - "name": "tropical fish" - }, - { - "emoji": "๐Ÿก", - "name": "blowfish" - }, - { - "emoji": "๐Ÿฆˆ", - "name": "shark" - }, - { - "emoji": "๐Ÿ™", - "name": "octopus" - }, - { - "emoji": "๐Ÿš", - "name": "spiral shell" - }, - { - "emoji": "๐Ÿชธ", - "name": "coral" - }, - { - "emoji": "๐Ÿชผ", - "name": "jellyfish" - }, - { - "emoji": "๐ŸŒ", - "name": "snail" - }, - { - "emoji": "๐Ÿฆ‹", - "name": "butterfly" - }, - { - "emoji": "๐Ÿ›", - "name": "bug" - }, - { - "emoji": "๐Ÿœ", - "name": "ant" - }, - { - "emoji": "๐Ÿ", - "name": "honeybee" - }, - { - "emoji": "๐Ÿชฒ", - "name": "beetle" - }, - { - "emoji": "๐Ÿž", - "name": "lady beetle" - }, - { - "emoji": "๐Ÿฆ—", - "name": "cricket" - }, - { - "emoji": "๐Ÿชณ", - "name": "cockroach" - }, - { - "emoji": "๐Ÿ•ท๏ธ", - "name": "spider" - }, - { - "emoji": "๐Ÿ•ธ๏ธ", - "name": "spider web" - }, - { - "emoji": "๐Ÿฆ‚", - "name": "scorpion" - }, - { - "emoji": "๐ŸฆŸ", - "name": "mosquito" - }, - { - "emoji": "๐Ÿชฐ", - "name": "fly" - }, - { - "emoji": "๐Ÿชฑ", - "name": "worm" - }, - { - "emoji": "๐Ÿฆ ", - "name": "microbe" - }, - { - "emoji": "๐Ÿ’", - "name": "bouquet" - }, - { - "emoji": "๐ŸŒธ", - "name": "cherry blossom" - }, - { - "emoji": "๐Ÿ’ฎ", - "name": "white flower" - }, - { - "emoji": "๐Ÿชท", - "name": "lotus" - }, - { - "emoji": "๐Ÿต๏ธ", - "name": "rosette" - }, - { - "emoji": "๐ŸŒน", - "name": "rose" - }, - { - "emoji": "๐Ÿฅ€", - "name": "wilted flower" - }, - { - "emoji": "๐ŸŒบ", - "name": "hibiscus" - }, - { - "emoji": "๐ŸŒป", - "name": "sunflower" - }, - { - "emoji": "๐ŸŒผ", - "name": "blossom" - }, - { - "emoji": "๐ŸŒท", - "name": "tulip" - }, - { - "emoji": "๐Ÿชป", - "name": "hyacinth" - }, - { - "emoji": "๐ŸŒฑ", - "name": "seedling" - }, - { - "emoji": "๐Ÿชด", - "name": "potted plant" - }, - { - "emoji": "๐ŸŒฒ", - "name": "evergreen tree" - }, - { - "emoji": "๐ŸŒณ", - "name": "deciduous tree" - }, - { - "emoji": "๐ŸŒด", - "name": "palm tree" - }, - { - "emoji": "๐ŸŒต", - "name": "cactus" - }, - { - "emoji": "๐ŸŒพ", - "name": "sheaf of rice" - }, - { - "emoji": "๐ŸŒฟ", - "name": "herb" - }, - { - "emoji": "โ˜˜๏ธ", - "name": "shamrock" - }, - { - "emoji": "๐Ÿ€", - "name": "four leaf clover" - }, - { - "emoji": "๐Ÿ", - "name": "maple leaf" - }, - { - "emoji": "๐Ÿ‚", - "name": "fallen leaf" - }, - { - "emoji": "๐Ÿƒ", - "name": "leaf fluttering in wind" - }, - { - "emoji": "๐Ÿชน", - "name": "empty nest" - }, - { - "emoji": "๐Ÿชบ", - "name": "nest with eggs" - }, - { - "emoji": "๐Ÿ„", - "name": "mushroom" - } - ], - "Food & Drink": [ - { - "emoji": "๐Ÿ‡", - "name": "grapes" - }, - { - "emoji": "๐Ÿˆ", - "name": "melon" - }, - { - "emoji": "๐Ÿ‰", - "name": "watermelon" - }, - { - "emoji": "๐ŸŠ", - "name": "tangerine" - }, - { - "emoji": "๐Ÿ‹", - "name": "lemon" - }, - { - "emoji": "๐Ÿ‹โ€๐ŸŸฉ", - "name": "lime" - }, - { - "emoji": "๐ŸŒ", - "name": "banana" - }, - { - "emoji": "๐Ÿ", - "name": "pineapple" - }, - { - "emoji": "๐Ÿฅญ", - "name": "mango" - }, - { - "emoji": "๐ŸŽ", - "name": "red apple" - }, - { - "emoji": "๐Ÿ", - "name": "green apple" - }, - { - "emoji": "๐Ÿ", - "name": "pear" - }, - { - "emoji": "๐Ÿ‘", - "name": "peach" - }, - { - "emoji": "๐Ÿ’", - "name": "cherries" - }, - { - "emoji": "๐Ÿ“", - "name": "strawberry" - }, - { - "emoji": "๐Ÿซ", - "name": "blueberries" - }, - { - "emoji": "๐Ÿฅ", - "name": "kiwi fruit" - }, - { - "emoji": "๐Ÿ…", - "name": "tomato" - }, - { - "emoji": "๐Ÿซ’", - "name": "olive" - }, - { - "emoji": "๐Ÿฅฅ", - "name": "coconut" - }, - { - "emoji": "๐Ÿฅ‘", - "name": "avocado" - }, - { - "emoji": "๐Ÿ†", - "name": "eggplant" - }, - { - "emoji": "๐Ÿฅ”", - "name": "potato" - }, - { - "emoji": "๐Ÿฅ•", - "name": "carrot" - }, - { - "emoji": "๐ŸŒฝ", - "name": "ear of corn" - }, - { - "emoji": "๐ŸŒถ๏ธ", - "name": "hot pepper" - }, - { - "emoji": "๐Ÿซ‘", - "name": "bell pepper" - }, - { - "emoji": "๐Ÿฅ’", - "name": "cucumber" - }, - { - "emoji": "๐Ÿฅฌ", - "name": "leafy green" - }, - { - "emoji": "๐Ÿฅฆ", - "name": "broccoli" - }, - { - "emoji": "๐Ÿง„", - "name": "garlic" - }, - { - "emoji": "๐Ÿง…", - "name": "onion" - }, - { - "emoji": "๐Ÿฅœ", - "name": "peanuts" - }, - { - "emoji": "๐Ÿซ˜", - "name": "beans" - }, - { - "emoji": "๐ŸŒฐ", - "name": "chestnut" - }, - { - "emoji": "๐Ÿซš", - "name": "ginger root" - }, - { - "emoji": "๐Ÿซ›", - "name": "pea pod" - }, - { - "emoji": "๐Ÿ„โ€๐ŸŸซ", - "name": "brown mushroom" - }, - { - "emoji": "๐Ÿž", - "name": "bread" - }, - { - "emoji": "๐Ÿฅ", - "name": "croissant" - }, - { - "emoji": "๐Ÿฅ–", - "name": "baguette bread" - }, - { - "emoji": "๐Ÿซ“", - "name": "flatbread" - }, - { - "emoji": "๐Ÿฅจ", - "name": "pretzel" - }, - { - "emoji": "๐Ÿฅฏ", - "name": "bagel" - }, - { - "emoji": "๐Ÿฅž", - "name": "pancakes" - }, - { - "emoji": "๐Ÿง‡", - "name": "waffle" - }, - { - "emoji": "๐Ÿง€", - "name": "cheese wedge" - }, - { - "emoji": "๐Ÿ–", - "name": "meat on bone" - }, - { - "emoji": "๐Ÿ—", - "name": "poultry leg" - }, - { - "emoji": "๐Ÿฅฉ", - "name": "cut of meat" - }, - { - "emoji": "๐Ÿฅ“", - "name": "bacon" - }, - { - "emoji": "๐Ÿ”", - "name": "hamburger" - }, - { - "emoji": "๐ŸŸ", - "name": "french fries" - }, - { - "emoji": "๐Ÿ•", - "name": "pizza" - }, - { - "emoji": "๐ŸŒญ", - "name": "hot dog" - }, - { - "emoji": "๐Ÿฅช", - "name": "sandwich" - }, - { - "emoji": "๐ŸŒฎ", - "name": "taco" - }, - { - "emoji": "๐ŸŒฏ", - "name": "burrito" - }, - { - "emoji": "๐Ÿซ”", - "name": "tamale" - }, - { - "emoji": "๐Ÿฅ™", - "name": "stuffed flatbread" - }, - { - "emoji": "๐Ÿง†", - "name": "falafel" - }, - { - "emoji": "๐Ÿฅš", - "name": "egg" - }, - { - "emoji": "๐Ÿณ", - "name": "cooking" - }, - { - "emoji": "๐Ÿฅ˜", - "name": "shallow pan of food" - }, - { - "emoji": "๐Ÿฒ", - "name": "pot of food" - }, - { - "emoji": "๐Ÿซ•", - "name": "fondue" - }, - { - "emoji": "๐Ÿฅฃ", - "name": "bowl with spoon" - }, - { - "emoji": "๐Ÿฅ—", - "name": "green salad" - }, - { - "emoji": "๐Ÿฟ", - "name": "popcorn" - }, - { - "emoji": "๐Ÿงˆ", - "name": "butter" - }, - { - "emoji": "๐Ÿง‚", - "name": "salt" - }, - { - "emoji": "๐Ÿฅซ", - "name": "canned food" - }, - { - "emoji": "๐Ÿฑ", - "name": "bento box" - }, - { - "emoji": "๐Ÿ˜", - "name": "rice cracker" - }, - { - "emoji": "๐Ÿ™", - "name": "rice ball" - }, - { - "emoji": "๐Ÿš", - "name": "cooked rice" - }, - { - "emoji": "๐Ÿ›", - "name": "curry rice" - }, - { - "emoji": "๐Ÿœ", - "name": "steaming bowl" - }, - { - "emoji": "๐Ÿ", - "name": "spaghetti" - }, - { - "emoji": "๐Ÿ ", - "name": "roasted sweet potato" - }, - { - "emoji": "๐Ÿข", - "name": "oden" - }, - { - "emoji": "๐Ÿฃ", - "name": "sushi" - }, - { - "emoji": "๐Ÿค", - "name": "fried shrimp" - }, - { - "emoji": "๐Ÿฅ", - "name": "fish cake with swirl" - }, - { - "emoji": "๐Ÿฅฎ", - "name": "moon cake" - }, - { - "emoji": "๐Ÿก", - "name": "dango" - }, - { - "emoji": "๐ŸฅŸ", - "name": "dumpling" - }, - { - "emoji": "๐Ÿฅ ", - "name": "fortune cookie" - }, - { - "emoji": "๐Ÿฅก", - "name": "takeout box" - }, - { - "emoji": "๐Ÿฆ€", - "name": "crab" - }, - { - "emoji": "๐Ÿฆž", - "name": "lobster" - }, - { - "emoji": "๐Ÿฆ", - "name": "shrimp" - }, - { - "emoji": "๐Ÿฆ‘", - "name": "squid" - }, - { - "emoji": "๐Ÿฆช", - "name": "oyster" - }, - { - "emoji": "๐Ÿฆ", - "name": "soft ice cream" - }, - { - "emoji": "๐Ÿง", - "name": "shaved ice" - }, - { - "emoji": "๐Ÿจ", - "name": "ice cream" - }, - { - "emoji": "๐Ÿฉ", - "name": "doughnut" - }, - { - "emoji": "๐Ÿช", - "name": "cookie" - }, - { - "emoji": "๐ŸŽ‚", - "name": "birthday cake" - }, - { - "emoji": "๐Ÿฐ", - "name": "shortcake" - }, - { - "emoji": "๐Ÿง", - "name": "cupcake" - }, - { - "emoji": "๐Ÿฅง", - "name": "pie" - }, - { - "emoji": "๐Ÿซ", - "name": "chocolate bar" - }, - { - "emoji": "๐Ÿฌ", - "name": "candy" - }, - { - "emoji": "๐Ÿญ", - "name": "lollipop" - }, - { - "emoji": "๐Ÿฎ", - "name": "custard" - }, - { - "emoji": "๐Ÿฏ", - "name": "honey pot" - }, - { - "emoji": "๐Ÿผ", - "name": "baby bottle" - }, - { - "emoji": "๐Ÿฅ›", - "name": "glass of milk" - }, - { - "emoji": "โ˜•", - "name": "hot beverage" - }, - { - "emoji": "๐Ÿซ–", - "name": "teapot" - }, - { - "emoji": "๐Ÿต", - "name": "teacup without handle" - }, - { - "emoji": "๐Ÿถ", - "name": "sake" - }, - { - "emoji": "๐Ÿพ", - "name": "bottle with popping cork" - }, - { - "emoji": "๐Ÿท", - "name": "wine glass" - }, - { - "emoji": "๐Ÿธ", - "name": "cocktail glass" - }, - { - "emoji": "๐Ÿน", - "name": "tropical drink" - }, - { - "emoji": "๐Ÿบ", - "name": "beer mug" - }, - { - "emoji": "๐Ÿป", - "name": "clinking beer mugs" - }, - { - "emoji": "๐Ÿฅ‚", - "name": "clinking glasses" - }, - { - "emoji": "๐Ÿฅƒ", - "name": "tumbler glass" - }, - { - "emoji": "๐Ÿซ—", - "name": "pouring liquid" - }, - { - "emoji": "๐Ÿฅค", - "name": "cup with straw" - }, - { - "emoji": "๐Ÿง‹", - "name": "bubble tea" - }, - { - "emoji": "๐Ÿงƒ", - "name": "beverage box" - }, - { - "emoji": "๐Ÿง‰", - "name": "mate" - }, - { - "emoji": "๐ŸงŠ", - "name": "ice" - }, - { - "emoji": "๐Ÿฅข", - "name": "chopsticks" - }, - { - "emoji": "๐Ÿฝ๏ธ", - "name": "fork and knife with plate" - }, - { - "emoji": "๐Ÿด", - "name": "fork and knife" - }, - { - "emoji": "๐Ÿฅ„", - "name": "spoon" - }, - { - "emoji": "๐Ÿ”ช", - "name": "kitchen knife" - }, - { - "emoji": "๐Ÿซ™", - "name": "jar" - }, - { - "emoji": "๐Ÿบ", - "name": "amphora" - } - ], - "Travel & Places": [ - { - "emoji": "๐ŸŒ", - "name": "globe showing Europe-Africa" - }, - { - "emoji": "๐ŸŒŽ", - "name": "globe showing Americas" - }, - { - "emoji": "๐ŸŒ", - "name": "globe showing Asia-Australia" - }, - { - "emoji": "๐ŸŒ", - "name": "globe with meridians" - }, - { - "emoji": "๐Ÿ—บ๏ธ", - "name": "world map" - }, - { - "emoji": "๐Ÿ—พ", - "name": "map of Japan" - }, - { - "emoji": "๐Ÿงญ", - "name": "compass" - }, - { - "emoji": "๐Ÿ”๏ธ", - "name": "snow-capped mountain" - }, - { - "emoji": "โ›ฐ๏ธ", - "name": "mountain" - }, - { - "emoji": "๐ŸŒ‹", - "name": "volcano" - }, - { - "emoji": "๐Ÿ—ป", - "name": "mount fuji" - }, - { - "emoji": "๐Ÿ•๏ธ", - "name": "camping" - }, - { - "emoji": "๐Ÿ–๏ธ", - "name": "beach with umbrella" - }, - { - "emoji": "๐Ÿœ๏ธ", - "name": "desert" - }, - { - "emoji": "๐Ÿ๏ธ", - "name": "desert island" - }, - { - "emoji": "๐Ÿž๏ธ", - "name": "national park" - }, - { - "emoji": "๐ŸŸ๏ธ", - "name": "stadium" - }, - { - "emoji": "๐Ÿ›๏ธ", - "name": "classical building" - }, - { - "emoji": "๐Ÿ—๏ธ", - "name": "building construction" - }, - { - "emoji": "๐Ÿงฑ", - "name": "brick" - }, - { - "emoji": "๐Ÿชจ", - "name": "rock" - }, - { - "emoji": "๐Ÿชต", - "name": "wood" - }, - { - "emoji": "๐Ÿ›–", - "name": "hut" - }, - { - "emoji": "๐Ÿ˜๏ธ", - "name": "houses" - }, - { - "emoji": "๐Ÿš๏ธ", - "name": "derelict house" - }, - { - "emoji": "๐Ÿ ", - "name": "house" - }, - { - "emoji": "๐Ÿก", - "name": "house with garden" - }, - { - "emoji": "๐Ÿข", - "name": "office building" - }, - { - "emoji": "๐Ÿฃ", - "name": "Japanese post office" - }, - { - "emoji": "๐Ÿค", - "name": "post office" - }, - { - "emoji": "๐Ÿฅ", - "name": "hospital" - }, - { - "emoji": "๐Ÿฆ", - "name": "bank" - }, - { - "emoji": "๐Ÿจ", - "name": "hotel" - }, - { - "emoji": "๐Ÿฉ", - "name": "love hotel" - }, - { - "emoji": "๐Ÿช", - "name": "convenience store" - }, - { - "emoji": "๐Ÿซ", - "name": "school" - }, - { - "emoji": "๐Ÿฌ", - "name": "department store" - }, - { - "emoji": "๐Ÿญ", - "name": "factory" - }, - { - "emoji": "๐Ÿฏ", - "name": "Japanese castle" - }, - { - "emoji": "๐Ÿฐ", - "name": "castle" - }, - { - "emoji": "๐Ÿ’’", - "name": "wedding" - }, - { - "emoji": "๐Ÿ—ผ", - "name": "Tokyo tower" - }, - { - "emoji": "๐Ÿ—ฝ", - "name": "Statue of Liberty" - }, - { - "emoji": "โ›ช", - "name": "church" - }, - { - "emoji": "๐Ÿ•Œ", - "name": "mosque" - }, - { - "emoji": "๐Ÿ›•", - "name": "hindu temple" - }, - { - "emoji": "๐Ÿ•", - "name": "synagogue" - }, - { - "emoji": "โ›ฉ๏ธ", - "name": "shinto shrine" - }, - { - "emoji": "๐Ÿ•‹", - "name": "kaaba" - }, - { - "emoji": "โ›ฒ", - "name": "fountain" - }, - { - "emoji": "โ›บ", - "name": "tent" - }, - { - "emoji": "๐ŸŒ", - "name": "foggy" - }, - { - "emoji": "๐ŸŒƒ", - "name": "night with stars" - }, - { - "emoji": "๐Ÿ™๏ธ", - "name": "cityscape" - }, - { - "emoji": "๐ŸŒ„", - "name": "sunrise over mountains" - }, - { - "emoji": "๐ŸŒ…", - "name": "sunrise" - }, - { - "emoji": "๐ŸŒ†", - "name": "cityscape at dusk" - }, - { - "emoji": "๐ŸŒ‡", - "name": "sunset" - }, - { - "emoji": "๐ŸŒ‰", - "name": "bridge at night" - }, - { - "emoji": "โ™จ๏ธ", - "name": "hot springs" - }, - { - "emoji": "๐ŸŽ ", - "name": "carousel horse" - }, - { - "emoji": "๐Ÿ›", - "name": "playground slide" - }, - { - "emoji": "๐ŸŽก", - "name": "ferris wheel" - }, - { - "emoji": "๐ŸŽข", - "name": "roller coaster" - }, - { - "emoji": "๐Ÿ’ˆ", - "name": "barber pole" - }, - { - "emoji": "๐ŸŽช", - "name": "circus tent" - }, - { - "emoji": "๐Ÿš‚", - "name": "locomotive" - }, - { - "emoji": "๐Ÿšƒ", - "name": "railway car" - }, - { - "emoji": "๐Ÿš„", - "name": "high-speed train" - }, - { - "emoji": "๐Ÿš…", - "name": "bullet train" - }, - { - "emoji": "๐Ÿš†", - "name": "train" - }, - { - "emoji": "๐Ÿš‡", - "name": "metro" - }, - { - "emoji": "๐Ÿšˆ", - "name": "light rail" - }, - { - "emoji": "๐Ÿš‰", - "name": "station" - }, - { - "emoji": "๐ŸšŠ", - "name": "tram" - }, - { - "emoji": "๐Ÿš", - "name": "monorail" - }, - { - "emoji": "๐Ÿšž", - "name": "mountain railway" - }, - { - "emoji": "๐Ÿš‹", - "name": "tram car" - }, - { - "emoji": "๐ŸšŒ", - "name": "bus" - }, - { - "emoji": "๐Ÿš", - "name": "oncoming bus" - }, - { - "emoji": "๐ŸšŽ", - "name": "trolleybus" - }, - { - "emoji": "๐Ÿš", - "name": "minibus" - }, - { - "emoji": "๐Ÿš‘", - "name": "ambulance" - }, - { - "emoji": "๐Ÿš’", - "name": "fire engine" - }, - { - "emoji": "๐Ÿš“", - "name": "police car" - }, - { - "emoji": "๐Ÿš”", - "name": "oncoming police car" - }, - { - "emoji": "๐Ÿš•", - "name": "taxi" - }, - { - "emoji": "๐Ÿš–", - "name": "oncoming taxi" - }, - { - "emoji": "๐Ÿš—", - "name": "automobile" - }, - { - "emoji": "๐Ÿš˜", - "name": "oncoming automobile" - }, - { - "emoji": "๐Ÿš™", - "name": "sport utility vehicle" - }, - { - "emoji": "๐Ÿ›ป", - "name": "pickup truck" - }, - { - "emoji": "๐Ÿšš", - "name": "delivery truck" - }, - { - "emoji": "๐Ÿš›", - "name": "articulated lorry" - }, - { - "emoji": "๐Ÿšœ", - "name": "tractor" - }, - { - "emoji": "๐ŸŽ๏ธ", - "name": "racing car" - }, - { - "emoji": "๐Ÿ๏ธ", - "name": "motorcycle" - }, - { - "emoji": "๐Ÿ›ต", - "name": "motor scooter" - }, - { - "emoji": "๐Ÿฆฝ", - "name": "manual wheelchair" - }, - { - "emoji": "๐Ÿฆผ", - "name": "motorized wheelchair" - }, - { - "emoji": "๐Ÿ›บ", - "name": "auto rickshaw" - }, - { - "emoji": "๐Ÿšฒ", - "name": "bicycle" - }, - { - "emoji": "๐Ÿ›ด", - "name": "kick scooter" - }, - { - "emoji": "๐Ÿ›น", - "name": "skateboard" - }, - { - "emoji": "๐Ÿ›ผ", - "name": "roller skate" - }, - { - "emoji": "๐Ÿš", - "name": "bus stop" - }, - { - "emoji": "๐Ÿ›ฃ๏ธ", - "name": "motorway" - }, - { - "emoji": "๐Ÿ›ค๏ธ", - "name": "railway track" - }, - { - "emoji": "๐Ÿ›ข๏ธ", - "name": "oil drum" - }, - { - "emoji": "โ›ฝ", - "name": "fuel pump" - }, - { - "emoji": "๐Ÿ›ž", - "name": "wheel" - }, - { - "emoji": "๐Ÿšจ", - "name": "police car light" - }, - { - "emoji": "๐Ÿšฅ", - "name": "horizontal traffic light" - }, - { - "emoji": "๐Ÿšฆ", - "name": "vertical traffic light" - }, - { - "emoji": "๐Ÿ›‘", - "name": "stop sign" - }, - { - "emoji": "๐Ÿšง", - "name": "construction" - }, - { - "emoji": "โš“", - "name": "anchor" - }, - { - "emoji": "๐Ÿ›Ÿ", - "name": "ring buoy" - }, - { - "emoji": "โ›ต", - "name": "sailboat" - }, - { - "emoji": "๐Ÿ›ถ", - "name": "canoe" - }, - { - "emoji": "๐Ÿšค", - "name": "speedboat" - }, - { - "emoji": "๐Ÿ›ณ๏ธ", - "name": "passenger ship" - }, - { - "emoji": "โ›ด๏ธ", - "name": "ferry" - }, - { - "emoji": "๐Ÿ›ฅ๏ธ", - "name": "motor boat" - }, - { - "emoji": "๐Ÿšข", - "name": "ship" - }, - { - "emoji": "โœˆ๏ธ", - "name": "airplane" - }, - { - "emoji": "๐Ÿ›ฉ๏ธ", - "name": "small airplane" - }, - { - "emoji": "๐Ÿ›ซ", - "name": "airplane departure" - }, - { - "emoji": "๐Ÿ›ฌ", - "name": "airplane arrival" - }, - { - "emoji": "๐Ÿช‚", - "name": "parachute" - }, - { - "emoji": "๐Ÿ’บ", - "name": "seat" - }, - { - "emoji": "๐Ÿš", - "name": "helicopter" - }, - { - "emoji": "๐ŸšŸ", - "name": "suspension railway" - }, - { - "emoji": "๐Ÿš ", - "name": "mountain cableway" - }, - { - "emoji": "๐Ÿšก", - "name": "aerial tramway" - }, - { - "emoji": "๐Ÿ›ฐ๏ธ", - "name": "satellite" - }, - { - "emoji": "๐Ÿš€", - "name": "rocket" - }, - { - "emoji": "๐Ÿ›ธ", - "name": "flying saucer" - }, - { - "emoji": "๐Ÿ›Ž๏ธ", - "name": "bellhop bell" - }, - { - "emoji": "๐Ÿงณ", - "name": "luggage" - }, - { - "emoji": "โŒ›", - "name": "hourglass done" - }, - { - "emoji": "โณ", - "name": "hourglass not done" - }, - { - "emoji": "โŒš", - "name": "watch" - }, - { - "emoji": "โฐ", - "name": "alarm clock" - }, - { - "emoji": "โฑ๏ธ", - "name": "stopwatch" - }, - { - "emoji": "โฒ๏ธ", - "name": "timer clock" - }, - { - "emoji": "๐Ÿ•ฐ๏ธ", - "name": "mantelpiece clock" - }, - { - "emoji": "๐Ÿ•›", - "name": "twelve oโ€™clock" - }, - { - "emoji": "๐Ÿ•ง", - "name": "twelve-thirty" - }, - { - "emoji": "๐Ÿ•", - "name": "one oโ€™clock" - }, - { - "emoji": "๐Ÿ•œ", - "name": "one-thirty" - }, - { - "emoji": "๐Ÿ•‘", - "name": "two oโ€™clock" - }, - { - "emoji": "๐Ÿ•", - "name": "two-thirty" - }, - { - "emoji": "๐Ÿ•’", - "name": "three oโ€™clock" - }, - { - "emoji": "๐Ÿ•ž", - "name": "three-thirty" - }, - { - "emoji": "๐Ÿ•“", - "name": "four oโ€™clock" - }, - { - "emoji": "๐Ÿ•Ÿ", - "name": "four-thirty" - }, - { - "emoji": "๐Ÿ•”", - "name": "five oโ€™clock" - }, - { - "emoji": "๐Ÿ• ", - "name": "five-thirty" - }, - { - "emoji": "๐Ÿ••", - "name": "six oโ€™clock" - }, - { - "emoji": "๐Ÿ•ก", - "name": "six-thirty" - }, - { - "emoji": "๐Ÿ•–", - "name": "seven oโ€™clock" - }, - { - "emoji": "๐Ÿ•ข", - "name": "seven-thirty" - }, - { - "emoji": "๐Ÿ•—", - "name": "eight oโ€™clock" - }, - { - "emoji": "๐Ÿ•ฃ", - "name": "eight-thirty" - }, - { - "emoji": "๐Ÿ•˜", - "name": "nine oโ€™clock" - }, - { - "emoji": "๐Ÿ•ค", - "name": "nine-thirty" - }, - { - "emoji": "๐Ÿ•™", - "name": "ten oโ€™clock" - }, - { - "emoji": "๐Ÿ•ฅ", - "name": "ten-thirty" - }, - { - "emoji": "๐Ÿ•š", - "name": "eleven oโ€™clock" - }, - { - "emoji": "๐Ÿ•ฆ", - "name": "eleven-thirty" - }, - { - "emoji": "๐ŸŒ‘", - "name": "new moon" - }, - { - "emoji": "๐ŸŒ’", - "name": "waxing crescent moon" - }, - { - "emoji": "๐ŸŒ“", - "name": "first quarter moon" - }, - { - "emoji": "๐ŸŒ”", - "name": "waxing gibbous moon" - }, - { - "emoji": "๐ŸŒ•", - "name": "full moon" - }, - { - "emoji": "๐ŸŒ–", - "name": "waning gibbous moon" - }, - { - "emoji": "๐ŸŒ—", - "name": "last quarter moon" - }, - { - "emoji": "๐ŸŒ˜", - "name": "waning crescent moon" - }, - { - "emoji": "๐ŸŒ™", - "name": "crescent moon" - }, - { - "emoji": "๐ŸŒš", - "name": "new moon face" - }, - { - "emoji": "๐ŸŒ›", - "name": "first quarter moon face" - }, - { - "emoji": "๐ŸŒœ", - "name": "last quarter moon face" - }, - { - "emoji": "๐ŸŒก๏ธ", - "name": "thermometer" - }, - { - "emoji": "โ˜€๏ธ", - "name": "sun" - }, - { - "emoji": "๐ŸŒ", - "name": "full moon face" - }, - { - "emoji": "๐ŸŒž", - "name": "sun with face" - }, - { - "emoji": "๐Ÿช", - "name": "ringed planet" - }, - { - "emoji": "โญ", - "name": "star" - }, - { - "emoji": "๐ŸŒŸ", - "name": "glowing star" - }, - { - "emoji": "๐ŸŒ ", - "name": "shooting star" - }, - { - "emoji": "๐ŸŒŒ", - "name": "milky way" - }, - { - "emoji": "โ˜๏ธ", - "name": "cloud" - }, - { - "emoji": "โ›…", - "name": "sun behind cloud" - }, - { - "emoji": "โ›ˆ๏ธ", - "name": "cloud with lightning and rain" - }, - { - "emoji": "๐ŸŒค๏ธ", - "name": "sun behind small cloud" - }, - { - "emoji": "๐ŸŒฅ๏ธ", - "name": "sun behind large cloud" - }, - { - "emoji": "๐ŸŒฆ๏ธ", - "name": "sun behind rain cloud" - }, - { - "emoji": "๐ŸŒง๏ธ", - "name": "cloud with rain" - }, - { - "emoji": "๐ŸŒจ๏ธ", - "name": "cloud with snow" - }, - { - "emoji": "๐ŸŒฉ๏ธ", - "name": "cloud with lightning" - }, - { - "emoji": "๐ŸŒช๏ธ", - "name": "tornado" - }, - { - "emoji": "๐ŸŒซ๏ธ", - "name": "fog" - }, - { - "emoji": "๐ŸŒฌ๏ธ", - "name": "wind face" - }, - { - "emoji": "๐ŸŒ€", - "name": "cyclone" - }, - { - "emoji": "๐ŸŒˆ", - "name": "rainbow" - }, - { - "emoji": "๐ŸŒ‚", - "name": "closed umbrella" - }, - { - "emoji": "โ˜‚๏ธ", - "name": "umbrella" - }, - { - "emoji": "โ˜”", - "name": "umbrella with rain drops" - }, - { - "emoji": "โ›ฑ๏ธ", - "name": "umbrella on ground" - }, - { - "emoji": "โšก", - "name": "high voltage" - }, - { - "emoji": "โ„๏ธ", - "name": "snowflake" - }, - { - "emoji": "โ˜ƒ๏ธ", - "name": "snowman" - }, - { - "emoji": "โ›„", - "name": "snowman without snow" - }, - { - "emoji": "โ˜„๏ธ", - "name": "comet" - }, - { - "emoji": "๐Ÿ”ฅ", - "name": "fire" - }, - { - "emoji": "๐Ÿ’ง", - "name": "droplet" - }, - { - "emoji": "๐ŸŒŠ", - "name": "water wave" - } - ], - "Activities": [ - { - "emoji": "๐ŸŽƒ", - "name": "jack-o-lantern" - }, - { - "emoji": "๐ŸŽ„", - "name": "Christmas tree" - }, - { - "emoji": "๐ŸŽ†", - "name": "fireworks" - }, - { - "emoji": "๐ŸŽ‡", - "name": "sparkler" - }, - { - "emoji": "๐Ÿงจ", - "name": "firecracker" - }, - { - "emoji": "โœจ", - "name": "sparkles" - }, - { - "emoji": "๐ŸŽˆ", - "name": "balloon" - }, - { - "emoji": "๐ŸŽ‰", - "name": "party popper" - }, - { - "emoji": "๐ŸŽŠ", - "name": "confetti ball" - }, - { - "emoji": "๐ŸŽ‹", - "name": "tanabata tree" - }, - { - "emoji": "๐ŸŽ", - "name": "pine decoration" - }, - { - "emoji": "๐ŸŽŽ", - "name": "Japanese dolls" - }, - { - "emoji": "๐ŸŽ", - "name": "carp streamer" - }, - { - "emoji": "๐ŸŽ", - "name": "wind chime" - }, - { - "emoji": "๐ŸŽ‘", - "name": "moon viewing ceremony" - }, - { - "emoji": "๐Ÿงง", - "name": "red envelope" - }, - { - "emoji": "๐ŸŽ€", - "name": "ribbon" - }, - { - "emoji": "๐ŸŽ", - "name": "wrapped gift" - }, - { - "emoji": "๐ŸŽ—๏ธ", - "name": "reminder ribbon" - }, - { - "emoji": "๐ŸŽŸ๏ธ", - "name": "admission tickets" - }, - { - "emoji": "๐ŸŽซ", - "name": "ticket" - }, - { - "emoji": "๐ŸŽ–๏ธ", - "name": "military medal" - }, - { - "emoji": "๐Ÿ†", - "name": "trophy" - }, - { - "emoji": "๐Ÿ…", - "name": "sports medal" - }, - { - "emoji": "๐Ÿฅ‡", - "name": "1st place medal" - }, - { - "emoji": "๐Ÿฅˆ", - "name": "2nd place medal" - }, - { - "emoji": "๐Ÿฅ‰", - "name": "3rd place medal" - }, - { - "emoji": "โšฝ", - "name": "soccer ball" - }, - { - "emoji": "โšพ", - "name": "baseball" - }, - { - "emoji": "๐ŸฅŽ", - "name": "softball" - }, - { - "emoji": "๐Ÿ€", - "name": "basketball" - }, - { - "emoji": "๐Ÿ", - "name": "volleyball" - }, - { - "emoji": "๐Ÿˆ", - "name": "american football" - }, - { - "emoji": "๐Ÿ‰", - "name": "rugby football" - }, - { - "emoji": "๐ŸŽพ", - "name": "tennis" - }, - { - "emoji": "๐Ÿฅ", - "name": "flying disc" - }, - { - "emoji": "๐ŸŽณ", - "name": "bowling" - }, - { - "emoji": "๐Ÿ", - "name": "cricket game" - }, - { - "emoji": "๐Ÿ‘", - "name": "field hockey" - }, - { - "emoji": "๐Ÿ’", - "name": "ice hockey" - }, - { - "emoji": "๐Ÿฅ", - "name": "lacrosse" - }, - { - "emoji": "๐Ÿ“", - "name": "ping pong" - }, - { - "emoji": "๐Ÿธ", - "name": "badminton" - }, - { - "emoji": "๐ŸฅŠ", - "name": "boxing glove" - }, - { - "emoji": "๐Ÿฅ‹", - "name": "martial arts uniform" - }, - { - "emoji": "๐Ÿฅ…", - "name": "goal net" - }, - { - "emoji": "โ›ณ", - "name": "flag in hole" - }, - { - "emoji": "โ›ธ๏ธ", - "name": "ice skate" - }, - { - "emoji": "๐ŸŽฃ", - "name": "fishing pole" - }, - { - "emoji": "๐Ÿคฟ", - "name": "diving mask" - }, - { - "emoji": "๐ŸŽฝ", - "name": "running shirt" - }, - { - "emoji": "๐ŸŽฟ", - "name": "skis" - }, - { - "emoji": "๐Ÿ›ท", - "name": "sled" - }, - { - "emoji": "๐ŸฅŒ", - "name": "curling stone" - }, - { - "emoji": "๐ŸŽฏ", - "name": "bullseye" - }, - { - "emoji": "๐Ÿช€", - "name": "yo-yo" - }, - { - "emoji": "๐Ÿช", - "name": "kite" - }, - { - "emoji": "๐Ÿ”ซ", - "name": "water pistol" - }, - { - "emoji": "๐ŸŽฑ", - "name": "pool 8 ball" - }, - { - "emoji": "๐Ÿ”ฎ", - "name": "crystal ball" - }, - { - "emoji": "๐Ÿช„", - "name": "magic wand" - }, - { - "emoji": "๐ŸŽฎ", - "name": "video game" - }, - { - "emoji": "๐Ÿ•น๏ธ", - "name": "joystick" - }, - { - "emoji": "๐ŸŽฐ", - "name": "slot machine" - }, - { - "emoji": "๐ŸŽฒ", - "name": "game die" - }, - { - "emoji": "๐Ÿงฉ", - "name": "puzzle piece" - }, - { - "emoji": "๐Ÿงธ", - "name": "teddy bear" - }, - { - "emoji": "๐Ÿช…", - "name": "piรฑata" - }, - { - "emoji": "๐Ÿชฉ", - "name": "mirror ball" - }, - { - "emoji": "๐Ÿช†", - "name": "nesting dolls" - }, - { - "emoji": "โ™ ๏ธ", - "name": "spade suit" - }, - { - "emoji": "โ™ฅ๏ธ", - "name": "heart suit" - }, - { - "emoji": "โ™ฆ๏ธ", - "name": "diamond suit" - }, - { - "emoji": "โ™ฃ๏ธ", - "name": "club suit" - }, - { - "emoji": "โ™Ÿ๏ธ", - "name": "chess pawn" - }, - { - "emoji": "๐Ÿƒ", - "name": "joker" - }, - { - "emoji": "๐Ÿ€„", - "name": "mahjong red dragon" - }, - { - "emoji": "๐ŸŽด", - "name": "flower playing cards" - }, - { - "emoji": "๐ŸŽญ", - "name": "performing arts" - }, - { - "emoji": "๐Ÿ–ผ๏ธ", - "name": "framed picture" - }, - { - "emoji": "๐ŸŽจ", - "name": "artist palette" - }, - { - "emoji": "๐Ÿงต", - "name": "thread" - }, - { - "emoji": "๐Ÿชก", - "name": "sewing needle" - }, - { - "emoji": "๐Ÿงถ", - "name": "yarn" - }, - { - "emoji": "๐Ÿชข", - "name": "knot" - } - ], - "Objects": [ - { - "emoji": "๐Ÿ‘“", - "name": "glasses" - }, - { - "emoji": "๐Ÿ•ถ๏ธ", - "name": "sunglasses" - }, - { - "emoji": "๐Ÿฅฝ", - "name": "goggles" - }, - { - "emoji": "๐Ÿฅผ", - "name": "lab coat" - }, - { - "emoji": "๐Ÿฆบ", - "name": "safety vest" - }, - { - "emoji": "๐Ÿ‘”", - "name": "necktie" - }, - { - "emoji": "๐Ÿ‘•", - "name": "t-shirt" - }, - { - "emoji": "๐Ÿ‘–", - "name": "jeans" - }, - { - "emoji": "๐Ÿงฃ", - "name": "scarf" - }, - { - "emoji": "๐Ÿงค", - "name": "gloves" - }, - { - "emoji": "๐Ÿงฅ", - "name": "coat" - }, - { - "emoji": "๐Ÿงฆ", - "name": "socks" - }, - { - "emoji": "๐Ÿ‘—", - "name": "dress" - }, - { - "emoji": "๐Ÿ‘˜", - "name": "kimono" - }, - { - "emoji": "๐Ÿฅป", - "name": "sari" - }, - { - "emoji": "๐Ÿฉฑ", - "name": "one-piece swimsuit" - }, - { - "emoji": "๐Ÿฉฒ", - "name": "briefs" - }, - { - "emoji": "๐Ÿฉณ", - "name": "shorts" - }, - { - "emoji": "๐Ÿ‘™", - "name": "bikini" - }, - { - "emoji": "๐Ÿ‘š", - "name": "womanโ€™s clothes" - }, - { - "emoji": "๐Ÿชญ", - "name": "folding hand fan" - }, - { - "emoji": "๐Ÿ‘›", - "name": "purse" - }, - { - "emoji": "๐Ÿ‘œ", - "name": "handbag" - }, - { - "emoji": "๐Ÿ‘", - "name": "clutch bag" - }, - { - "emoji": "๐Ÿ›๏ธ", - "name": "shopping bags" - }, - { - "emoji": "๐ŸŽ’", - "name": "backpack" - }, - { - "emoji": "๐Ÿฉด", - "name": "thong sandal" - }, - { - "emoji": "๐Ÿ‘ž", - "name": "manโ€™s shoe" - }, - { - "emoji": "๐Ÿ‘Ÿ", - "name": "running shoe" - }, - { - "emoji": "๐Ÿฅพ", - "name": "hiking boot" - }, - { - "emoji": "๐Ÿฅฟ", - "name": "flat shoe" - }, - { - "emoji": "๐Ÿ‘ ", - "name": "high-heeled shoe" - }, - { - "emoji": "๐Ÿ‘ก", - "name": "womanโ€™s sandal" - }, - { - "emoji": "๐Ÿฉฐ", - "name": "ballet shoes" - }, - { - "emoji": "๐Ÿ‘ข", - "name": "womanโ€™s boot" - }, - { - "emoji": "๐Ÿชฎ", - "name": "hair pick" - }, - { - "emoji": "๐Ÿ‘‘", - "name": "crown" - }, - { - "emoji": "๐Ÿ‘’", - "name": "womanโ€™s hat" - }, - { - "emoji": "๐ŸŽฉ", - "name": "top hat" - }, - { - "emoji": "๐ŸŽ“", - "name": "graduation cap" - }, - { - "emoji": "๐Ÿงข", - "name": "billed cap" - }, - { - "emoji": "๐Ÿช–", - "name": "military helmet" - }, - { - "emoji": "โ›‘๏ธ", - "name": "rescue workerโ€™s helmet" - }, - { - "emoji": "๐Ÿ“ฟ", - "name": "prayer beads" - }, - { - "emoji": "๐Ÿ’„", - "name": "lipstick" - }, - { - "emoji": "๐Ÿ’", - "name": "ring" - }, - { - "emoji": "๐Ÿ’Ž", - "name": "gem stone" - }, - { - "emoji": "๐Ÿ”‡", - "name": "muted speaker" - }, - { - "emoji": "๐Ÿ”ˆ", - "name": "speaker low volume" - }, - { - "emoji": "๐Ÿ”‰", - "name": "speaker medium volume" - }, - { - "emoji": "๐Ÿ”Š", - "name": "speaker high volume" - }, - { - "emoji": "๐Ÿ“ข", - "name": "loudspeaker" - }, - { - "emoji": "๐Ÿ“ฃ", - "name": "megaphone" - }, - { - "emoji": "๐Ÿ“ฏ", - "name": "postal horn" - }, - { - "emoji": "๐Ÿ””", - "name": "bell" - }, - { - "emoji": "๐Ÿ”•", - "name": "bell with slash" - }, - { - "emoji": "๐ŸŽผ", - "name": "musical score" - }, - { - "emoji": "๐ŸŽต", - "name": "musical note" - }, - { - "emoji": "๐ŸŽถ", - "name": "musical notes" - }, - { - "emoji": "๐ŸŽ™๏ธ", - "name": "studio microphone" - }, - { - "emoji": "๐ŸŽš๏ธ", - "name": "level slider" - }, - { - "emoji": "๐ŸŽ›๏ธ", - "name": "control knobs" - }, - { - "emoji": "๐ŸŽค", - "name": "microphone" - }, - { - "emoji": "๐ŸŽง", - "name": "headphone" - }, - { - "emoji": "๐Ÿ“ป", - "name": "radio" - }, - { - "emoji": "๐ŸŽท", - "name": "saxophone" - }, - { - "emoji": "๐Ÿช—", - "name": "accordion" - }, - { - "emoji": "๐ŸŽธ", - "name": "guitar" - }, - { - "emoji": "๐ŸŽน", - "name": "musical keyboard" - }, - { - "emoji": "๐ŸŽบ", - "name": "trumpet" - }, - { - "emoji": "๐ŸŽป", - "name": "violin" - }, - { - "emoji": "๐Ÿช•", - "name": "banjo" - }, - { - "emoji": "๐Ÿฅ", - "name": "drum" - }, - { - "emoji": "๐Ÿช˜", - "name": "long drum" - }, - { - "emoji": "๐Ÿช‡", - "name": "maracas" - }, - { - "emoji": "๐Ÿชˆ", - "name": "flute" - }, - { - "emoji": "๐Ÿ“ฑ", - "name": "mobile phone" - }, - { - "emoji": "๐Ÿ“ฒ", - "name": "mobile phone with arrow" - }, - { - "emoji": "โ˜Ž๏ธ", - "name": "telephone" - }, - { - "emoji": "๐Ÿ“ž", - "name": "telephone receiver" - }, - { - "emoji": "๐Ÿ“Ÿ", - "name": "pager" - }, - { - "emoji": "๐Ÿ“ ", - "name": "fax machine" - }, - { - "emoji": "๐Ÿ”‹", - "name": "battery" - }, - { - "emoji": "๐Ÿชซ", - "name": "low battery" - }, - { - "emoji": "๐Ÿ”Œ", - "name": "electric plug" - }, - { - "emoji": "๐Ÿ’ป", - "name": "laptop" - }, - { - "emoji": "๐Ÿ–ฅ๏ธ", - "name": "desktop computer" - }, - { - "emoji": "๐Ÿ–จ๏ธ", - "name": "printer" - }, - { - "emoji": "โŒจ๏ธ", - "name": "keyboard" - }, - { - "emoji": "๐Ÿ–ฑ๏ธ", - "name": "computer mouse" - }, - { - "emoji": "๐Ÿ–ฒ๏ธ", - "name": "trackball" - }, - { - "emoji": "๐Ÿ’ฝ", - "name": "computer disk" - }, - { - "emoji": "๐Ÿ’พ", - "name": "floppy disk" - }, - { - "emoji": "๐Ÿ’ฟ", - "name": "optical disk" - }, - { - "emoji": "๐Ÿ“€", - "name": "dvd" - }, - { - "emoji": "๐Ÿงฎ", - "name": "abacus" - }, - { - "emoji": "๐ŸŽฅ", - "name": "movie camera" - }, - { - "emoji": "๐ŸŽž๏ธ", - "name": "film frames" - }, - { - "emoji": "๐Ÿ“ฝ๏ธ", - "name": "film projector" - }, - { - "emoji": "๐ŸŽฌ", - "name": "clapper board" - }, - { - "emoji": "๐Ÿ“บ", - "name": "television" - }, - { - "emoji": "๐Ÿ“ท", - "name": "camera" - }, - { - "emoji": "๐Ÿ“ธ", - "name": "camera with flash" - }, - { - "emoji": "๐Ÿ“น", - "name": "video camera" - }, - { - "emoji": "๐Ÿ“ผ", - "name": "videocassette" - }, - { - "emoji": "๐Ÿ”", - "name": "magnifying glass tilted left" - }, - { - "emoji": "๐Ÿ”Ž", - "name": "magnifying glass tilted right" - }, - { - "emoji": "๐Ÿ•ฏ๏ธ", - "name": "candle" - }, - { - "emoji": "๐Ÿ’ก", - "name": "light bulb" - }, - { - "emoji": "๐Ÿ”ฆ", - "name": "flashlight" - }, - { - "emoji": "๐Ÿฎ", - "name": "red paper lantern" - }, - { - "emoji": "๐Ÿช”", - "name": "diya lamp" - }, - { - "emoji": "๐Ÿ“”", - "name": "notebook with decorative cover" - }, - { - "emoji": "๐Ÿ“•", - "name": "closed book" - }, - { - "emoji": "๐Ÿ“–", - "name": "open book" - }, - { - "emoji": "๐Ÿ“—", - "name": "green book" - }, - { - "emoji": "๐Ÿ“˜", - "name": "blue book" - }, - { - "emoji": "๐Ÿ“™", - "name": "orange book" - }, - { - "emoji": "๐Ÿ“š", - "name": "books" - }, - { - "emoji": "๐Ÿ““", - "name": "notebook" - }, - { - "emoji": "๐Ÿ“’", - "name": "ledger" - }, - { - "emoji": "๐Ÿ“ƒ", - "name": "page with curl" - }, - { - "emoji": "๐Ÿ“œ", - "name": "scroll" - }, - { - "emoji": "๐Ÿ“„", - "name": "page facing up" - }, - { - "emoji": "๐Ÿ“ฐ", - "name": "newspaper" - }, - { - "emoji": "๐Ÿ—ž๏ธ", - "name": "rolled-up newspaper" - }, - { - "emoji": "๐Ÿ“‘", - "name": "bookmark tabs" - }, - { - "emoji": "๐Ÿ”–", - "name": "bookmark" - }, - { - "emoji": "๐Ÿท๏ธ", - "name": "label" - }, - { - "emoji": "๐Ÿ’ฐ", - "name": "money bag" - }, - { - "emoji": "๐Ÿช™", - "name": "coin" - }, - { - "emoji": "๐Ÿ’ด", - "name": "yen banknote" - }, - { - "emoji": "๐Ÿ’ต", - "name": "dollar banknote" - }, - { - "emoji": "๐Ÿ’ถ", - "name": "euro banknote" - }, - { - "emoji": "๐Ÿ’ท", - "name": "pound banknote" - }, - { - "emoji": "๐Ÿ’ธ", - "name": "money with wings" - }, - { - "emoji": "๐Ÿ’ณ", - "name": "credit card" - }, - { - "emoji": "๐Ÿงพ", - "name": "receipt" - }, - { - "emoji": "๐Ÿ’น", - "name": "chart increasing with yen" - }, - { - "emoji": "โœ‰๏ธ", - "name": "envelope" - }, - { - "emoji": "๐Ÿ“ง", - "name": "e-mail" - }, - { - "emoji": "๐Ÿ“จ", - "name": "incoming envelope" - }, - { - "emoji": "๐Ÿ“ฉ", - "name": "envelope with arrow" - }, - { - "emoji": "๐Ÿ“ค", - "name": "outbox tray" - }, - { - "emoji": "๐Ÿ“ฅ", - "name": "inbox tray" - }, - { - "emoji": "๐Ÿ“ฆ", - "name": "package" - }, - { - "emoji": "๐Ÿ“ซ", - "name": "closed mailbox with raised flag" - }, - { - "emoji": "๐Ÿ“ช", - "name": "closed mailbox with lowered flag" - }, - { - "emoji": "๐Ÿ“ฌ", - "name": "open mailbox with raised flag" - }, - { - "emoji": "๐Ÿ“ญ", - "name": "open mailbox with lowered flag" - }, - { - "emoji": "๐Ÿ“ฎ", - "name": "postbox" - }, - { - "emoji": "๐Ÿ—ณ๏ธ", - "name": "ballot box with ballot" - }, - { - "emoji": "โœ๏ธ", - "name": "pencil" - }, - { - "emoji": "โœ’๏ธ", - "name": "black nib" - }, - { - "emoji": "๐Ÿ–‹๏ธ", - "name": "fountain pen" - }, - { - "emoji": "๐Ÿ–Š๏ธ", - "name": "pen" - }, - { - "emoji": "๐Ÿ–Œ๏ธ", - "name": "paintbrush" - }, - { - "emoji": "๐Ÿ–๏ธ", - "name": "crayon" - }, - { - "emoji": "๐Ÿ“", - "name": "memo" - }, - { - "emoji": "๐Ÿ’ผ", - "name": "briefcase" - }, - { - "emoji": "๐Ÿ“", - "name": "file folder" - }, - { - "emoji": "๐Ÿ“‚", - "name": "open file folder" - }, - { - "emoji": "๐Ÿ—‚๏ธ", - "name": "card index dividers" - }, - { - "emoji": "๐Ÿ“…", - "name": "calendar" - }, - { - "emoji": "๐Ÿ“†", - "name": "tear-off calendar" - }, - { - "emoji": "๐Ÿ—’๏ธ", - "name": "spiral notepad" - }, - { - "emoji": "๐Ÿ—“๏ธ", - "name": "spiral calendar" - }, - { - "emoji": "๐Ÿ“‡", - "name": "card index" - }, - { - "emoji": "๐Ÿ“ˆ", - "name": "chart increasing" - }, - { - "emoji": "๐Ÿ“‰", - "name": "chart decreasing" - }, - { - "emoji": "๐Ÿ“Š", - "name": "bar chart" - }, - { - "emoji": "๐Ÿ“‹", - "name": "clipboard" - }, - { - "emoji": "๐Ÿ“Œ", - "name": "pushpin" - }, - { - "emoji": "๐Ÿ“", - "name": "round pushpin" - }, - { - "emoji": "๐Ÿ“Ž", - "name": "paperclip" - }, - { - "emoji": "๐Ÿ–‡๏ธ", - "name": "linked paperclips" - }, - { - "emoji": "๐Ÿ“", - "name": "straight ruler" - }, - { - "emoji": "๐Ÿ“", - "name": "triangular ruler" - }, - { - "emoji": "โœ‚๏ธ", - "name": "scissors" - }, - { - "emoji": "๐Ÿ—ƒ๏ธ", - "name": "card file box" - }, - { - "emoji": "๐Ÿ—„๏ธ", - "name": "file cabinet" - }, - { - "emoji": "๐Ÿ—‘๏ธ", - "name": "wastebasket" - }, - { - "emoji": "๐Ÿ”’", - "name": "locked" - }, - { - "emoji": "๐Ÿ”“", - "name": "unlocked" - }, - { - "emoji": "๐Ÿ”", - "name": "locked with pen" - }, - { - "emoji": "๐Ÿ”", - "name": "locked with key" - }, - { - "emoji": "๐Ÿ”‘", - "name": "key" - }, - { - "emoji": "๐Ÿ—๏ธ", - "name": "old key" - }, - { - "emoji": "๐Ÿ”จ", - "name": "hammer" - }, - { - "emoji": "๐Ÿช“", - "name": "axe" - }, - { - "emoji": "โ›๏ธ", - "name": "pick" - }, - { - "emoji": "โš’๏ธ", - "name": "hammer and pick" - }, - { - "emoji": "๐Ÿ› ๏ธ", - "name": "hammer and wrench" - }, - { - "emoji": "๐Ÿ—ก๏ธ", - "name": "dagger" - }, - { - "emoji": "โš”๏ธ", - "name": "crossed swords" - }, - { - "emoji": "๐Ÿ’ฃ", - "name": "bomb" - }, - { - "emoji": "๐Ÿชƒ", - "name": "boomerang" - }, - { - "emoji": "๐Ÿน", - "name": "bow and arrow" - }, - { - "emoji": "๐Ÿ›ก๏ธ", - "name": "shield" - }, - { - "emoji": "๐Ÿชš", - "name": "carpentry saw" - }, - { - "emoji": "๐Ÿ”ง", - "name": "wrench" - }, - { - "emoji": "๐Ÿช›", - "name": "screwdriver" - }, - { - "emoji": "๐Ÿ”ฉ", - "name": "nut and bolt" - }, - { - "emoji": "โš™๏ธ", - "name": "gear" - }, - { - "emoji": "๐Ÿ—œ๏ธ", - "name": "clamp" - }, - { - "emoji": "โš–๏ธ", - "name": "balance scale" - }, - { - "emoji": "๐Ÿฆฏ", - "name": "white cane" - }, - { - "emoji": "๐Ÿ”—", - "name": "link" - }, - { - "emoji": "โ›“๏ธโ€๐Ÿ’ฅ", - "name": "broken chain" - }, - { - "emoji": "โ›“๏ธ", - "name": "chains" - }, - { - "emoji": "๐Ÿช", - "name": "hook" - }, - { - "emoji": "๐Ÿงฐ", - "name": "toolbox" - }, - { - "emoji": "๐Ÿงฒ", - "name": "magnet" - }, - { - "emoji": "๐Ÿชœ", - "name": "ladder" - }, - { - "emoji": "โš—๏ธ", - "name": "alembic" - }, - { - "emoji": "๐Ÿงช", - "name": "test tube" - }, - { - "emoji": "๐Ÿงซ", - "name": "petri dish" - }, - { - "emoji": "๐Ÿงฌ", - "name": "dna" - }, - { - "emoji": "๐Ÿ”ฌ", - "name": "microscope" - }, - { - "emoji": "๐Ÿ”ญ", - "name": "telescope" - }, - { - "emoji": "๐Ÿ“ก", - "name": "satellite antenna" - }, - { - "emoji": "๐Ÿ’‰", - "name": "syringe" - }, - { - "emoji": "๐Ÿฉธ", - "name": "drop of blood" - }, - { - "emoji": "๐Ÿ’Š", - "name": "pill" - }, - { - "emoji": "๐Ÿฉน", - "name": "adhesive bandage" - }, - { - "emoji": "๐Ÿฉผ", - "name": "crutch" - }, - { - "emoji": "๐Ÿฉบ", - "name": "stethoscope" - }, - { - "emoji": "๐Ÿฉป", - "name": "x-ray" - }, - { - "emoji": "๐Ÿšช", - "name": "door" - }, - { - "emoji": "๐Ÿ›—", - "name": "elevator" - }, - { - "emoji": "๐Ÿชž", - "name": "mirror" - }, - { - "emoji": "๐ŸชŸ", - "name": "window" - }, - { - "emoji": "๐Ÿ›๏ธ", - "name": "bed" - }, - { - "emoji": "๐Ÿ›‹๏ธ", - "name": "couch and lamp" - }, - { - "emoji": "๐Ÿช‘", - "name": "chair" - }, - { - "emoji": "๐Ÿšฝ", - "name": "toilet" - }, - { - "emoji": "๐Ÿช ", - "name": "plunger" - }, - { - "emoji": "๐Ÿšฟ", - "name": "shower" - }, - { - "emoji": "๐Ÿ›", - "name": "bathtub" - }, - { - "emoji": "๐Ÿชค", - "name": "mouse trap" - }, - { - "emoji": "๐Ÿช’", - "name": "razor" - }, - { - "emoji": "๐Ÿงด", - "name": "lotion bottle" - }, - { - "emoji": "๐Ÿงท", - "name": "safety pin" - }, - { - "emoji": "๐Ÿงน", - "name": "broom" - }, - { - "emoji": "๐Ÿงบ", - "name": "basket" - }, - { - "emoji": "๐Ÿงป", - "name": "roll of paper" - }, - { - "emoji": "๐Ÿชฃ", - "name": "bucket" - }, - { - "emoji": "๐Ÿงผ", - "name": "soap" - }, - { - "emoji": "๐Ÿซง", - "name": "bubbles" - }, - { - "emoji": "๐Ÿชฅ", - "name": "toothbrush" - }, - { - "emoji": "๐Ÿงฝ", - "name": "sponge" - }, - { - "emoji": "๐Ÿงฏ", - "name": "fire extinguisher" - }, - { - "emoji": "๐Ÿ›’", - "name": "shopping cart" - }, - { - "emoji": "๐Ÿšฌ", - "name": "cigarette" - }, - { - "emoji": "โšฐ๏ธ", - "name": "coffin" - }, - { - "emoji": "๐Ÿชฆ", - "name": "headstone" - }, - { - "emoji": "โšฑ๏ธ", - "name": "funeral urn" - }, - { - "emoji": "๐Ÿงฟ", - "name": "nazar amulet" - }, - { - "emoji": "๐Ÿชฌ", - "name": "hamsa" - }, - { - "emoji": "๐Ÿ—ฟ", - "name": "moai" - }, - { - "emoji": "๐Ÿชง", - "name": "placard" - }, - { - "emoji": "๐Ÿชช", - "name": "identification card" - } - ], - "Symbols": [ - { - "emoji": "๐Ÿง", - "name": "ATM sign" - }, - { - "emoji": "๐Ÿšฎ", - "name": "litter in bin sign" - }, - { - "emoji": "๐Ÿšฐ", - "name": "potable water" - }, - { - "emoji": "โ™ฟ", - "name": "wheelchair symbol" - }, - { - "emoji": "๐Ÿšน", - "name": "menโ€™s room" - }, - { - "emoji": "๐Ÿšบ", - "name": "womenโ€™s room" - }, - { - "emoji": "๐Ÿšป", - "name": "restroom" - }, - { - "emoji": "๐Ÿšผ", - "name": "baby symbol" - }, - { - "emoji": "๐Ÿšพ", - "name": "water closet" - }, - { - "emoji": "๐Ÿ›‚", - "name": "passport control" - }, - { - "emoji": "๐Ÿ›ƒ", - "name": "customs" - }, - { - "emoji": "๐Ÿ›„", - "name": "baggage claim" - }, - { - "emoji": "๐Ÿ›…", - "name": "left luggage" - }, - { - "emoji": "โš ๏ธ", - "name": "warning" - }, - { - "emoji": "๐Ÿšธ", - "name": "children crossing" - }, - { - "emoji": "โ›”", - "name": "no entry" - }, - { - "emoji": "๐Ÿšซ", - "name": "prohibited" - }, - { - "emoji": "๐Ÿšณ", - "name": "no bicycles" - }, - { - "emoji": "๐Ÿšญ", - "name": "no smoking" - }, - { - "emoji": "๐Ÿšฏ", - "name": "no littering" - }, - { - "emoji": "๐Ÿšฑ", - "name": "non-potable water" - }, - { - "emoji": "๐Ÿšท", - "name": "no pedestrians" - }, - { - "emoji": "๐Ÿ“ต", - "name": "no mobile phones" - }, - { - "emoji": "๐Ÿ”ž", - "name": "no one under eighteen" - }, - { - "emoji": "โ˜ข๏ธ", - "name": "radioactive" - }, - { - "emoji": "โ˜ฃ๏ธ", - "name": "biohazard" - }, - { - "emoji": "โฌ†๏ธ", - "name": "up arrow" - }, - { - "emoji": "โ†—๏ธ", - "name": "up-right arrow" - }, - { - "emoji": "โžก๏ธ", - "name": "right arrow" - }, - { - "emoji": "โ†˜๏ธ", - "name": "down-right arrow" - }, - { - "emoji": "โฌ‡๏ธ", - "name": "down arrow" - }, - { - "emoji": "โ†™๏ธ", - "name": "down-left arrow" - }, - { - "emoji": "โฌ…๏ธ", - "name": "left arrow" - }, - { - "emoji": "โ†–๏ธ", - "name": "up-left arrow" - }, - { - "emoji": "โ†•๏ธ", - "name": "up-down arrow" - }, - { - "emoji": "โ†”๏ธ", - "name": "left-right arrow" - }, - { - "emoji": "โ†ฉ๏ธ", - "name": "right arrow curving left" - }, - { - "emoji": "โ†ช๏ธ", - "name": "left arrow curving right" - }, - { - "emoji": "โคด๏ธ", - "name": "right arrow curving up" - }, - { - "emoji": "โคต๏ธ", - "name": "right arrow curving down" - }, - { - "emoji": "๐Ÿ”ƒ", - "name": "clockwise vertical arrows" - }, - { - "emoji": "๐Ÿ”„", - "name": "counterclockwise arrows button" - }, - { - "emoji": "๐Ÿ”™", - "name": "BACK arrow" - }, - { - "emoji": "๐Ÿ”š", - "name": "END arrow" - }, - { - "emoji": "๐Ÿ”›", - "name": "ON! arrow" - }, - { - "emoji": "๐Ÿ”œ", - "name": "SOON arrow" - }, - { - "emoji": "๐Ÿ”", - "name": "TOP arrow" - }, - { - "emoji": "๐Ÿ›", - "name": "place of worship" - }, - { - "emoji": "โš›๏ธ", - "name": "atom symbol" - }, - { - "emoji": "๐Ÿ•‰๏ธ", - "name": "om" - }, - { - "emoji": "โœก๏ธ", - "name": "star of David" - }, - { - "emoji": "โ˜ธ๏ธ", - "name": "wheel of dharma" - }, - { - "emoji": "โ˜ฏ๏ธ", - "name": "yin yang" - }, - { - "emoji": "โœ๏ธ", - "name": "latin cross" - }, - { - "emoji": "โ˜ฆ๏ธ", - "name": "orthodox cross" - }, - { - "emoji": "โ˜ช๏ธ", - "name": "star and crescent" - }, - { - "emoji": "โ˜ฎ๏ธ", - "name": "peace symbol" - }, - { - "emoji": "๐Ÿ•Ž", - "name": "menorah" - }, - { - "emoji": "๐Ÿ”ฏ", - "name": "dotted six-pointed star" - }, - { - "emoji": "๐Ÿชฏ", - "name": "khanda" - }, - { - "emoji": "โ™ˆ", - "name": "Aries" - }, - { - "emoji": "โ™‰", - "name": "Taurus" - }, - { - "emoji": "โ™Š", - "name": "Gemini" - }, - { - "emoji": "โ™‹", - "name": "Cancer" - }, - { - "emoji": "โ™Œ", - "name": "Leo" - }, - { - "emoji": "โ™", - "name": "Virgo" - }, - { - "emoji": "โ™Ž", - "name": "Libra" - }, - { - "emoji": "โ™", - "name": "Scorpio" - }, - { - "emoji": "โ™", - "name": "Sagittarius" - }, - { - "emoji": "โ™‘", - "name": "Capricorn" - }, - { - "emoji": "โ™’", - "name": "Aquarius" - }, - { - "emoji": "โ™“", - "name": "Pisces" - }, - { - "emoji": "โ›Ž", - "name": "Ophiuchus" - }, - { - "emoji": "๐Ÿ”€", - "name": "shuffle tracks button" - }, - { - "emoji": "๐Ÿ”", - "name": "repeat button" - }, - { - "emoji": "๐Ÿ”‚", - "name": "repeat single button" - }, - { - "emoji": "โ–ถ๏ธ", - "name": "play button" - }, - { - "emoji": "โฉ", - "name": "fast-forward button" - }, - { - "emoji": "โญ๏ธ", - "name": "next track button" - }, - { - "emoji": "โฏ๏ธ", - "name": "play or pause button" - }, - { - "emoji": "โ—€๏ธ", - "name": "reverse button" - }, - { - "emoji": "โช", - "name": "fast reverse button" - }, - { - "emoji": "โฎ๏ธ", - "name": "last track button" - }, - { - "emoji": "๐Ÿ”ผ", - "name": "upwards button" - }, - { - "emoji": "โซ", - "name": "fast up button" - }, - { - "emoji": "๐Ÿ”ฝ", - "name": "downwards button" - }, - { - "emoji": "โฌ", - "name": "fast down button" - }, - { - "emoji": "โธ๏ธ", - "name": "pause button" - }, - { - "emoji": "โน๏ธ", - "name": "stop button" - }, - { - "emoji": "โบ๏ธ", - "name": "record button" - }, - { - "emoji": "โ๏ธ", - "name": "eject button" - }, - { - "emoji": "๐ŸŽฆ", - "name": "cinema" - }, - { - "emoji": "๐Ÿ”…", - "name": "dim button" - }, - { - "emoji": "๐Ÿ”†", - "name": "bright button" - }, - { - "emoji": "๐Ÿ“ถ", - "name": "antenna bars" - }, - { - "emoji": "๐Ÿ›œ", - "name": "wireless" - }, - { - "emoji": "๐Ÿ“ณ", - "name": "vibration mode" - }, - { - "emoji": "๐Ÿ“ด", - "name": "mobile phone off" - }, - { - "emoji": "โ™€๏ธ", - "name": "female sign" - }, - { - "emoji": "โ™‚๏ธ", - "name": "male sign" - }, - { - "emoji": "โšง๏ธ", - "name": "transgender symbol" - }, - { - "emoji": "โœ–๏ธ", - "name": "multiply" - }, - { - "emoji": "โž•", - "name": "plus" - }, - { - "emoji": "โž–", - "name": "minus" - }, - { - "emoji": "โž—", - "name": "divide" - }, - { - "emoji": "๐ŸŸฐ", - "name": "heavy equals sign" - }, - { - "emoji": "โ™พ๏ธ", - "name": "infinity" - }, - { - "emoji": "โ€ผ๏ธ", - "name": "double exclamation mark" - }, - { - "emoji": "โ‰๏ธ", - "name": "exclamation question mark" - }, - { - "emoji": "โ“", - "name": "red question mark" - }, - { - "emoji": "โ”", - "name": "white question mark" - }, - { - "emoji": "โ•", - "name": "white exclamation mark" - }, - { - "emoji": "โ—", - "name": "red exclamation mark" - }, - { - "emoji": "ใ€ฐ๏ธ", - "name": "wavy dash" - }, - { - "emoji": "๐Ÿ’ฑ", - "name": "currency exchange" - }, - { - "emoji": "๐Ÿ’ฒ", - "name": "heavy dollar sign" - }, - { - "emoji": "โš•๏ธ", - "name": "medical symbol" - }, - { - "emoji": "โ™ป๏ธ", - "name": "recycling symbol" - }, - { - "emoji": "โšœ๏ธ", - "name": "fleur-de-lis" - }, - { - "emoji": "๐Ÿ”ฑ", - "name": "trident emblem" - }, - { - "emoji": "๐Ÿ“›", - "name": "name badge" - }, - { - "emoji": "๐Ÿ”ฐ", - "name": "Japanese symbol for beginner" - }, - { - "emoji": "โญ•", - "name": "hollow red circle" - }, - { - "emoji": "โœ…", - "name": "check mark button" - }, - { - "emoji": "โ˜‘๏ธ", - "name": "check box with check" - }, - { - "emoji": "โœ”๏ธ", - "name": "check mark" - }, - { - "emoji": "โŒ", - "name": "cross mark" - }, - { - "emoji": "โŽ", - "name": "cross mark button" - }, - { - "emoji": "โžฐ", - "name": "curly loop" - }, - { - "emoji": "โžฟ", - "name": "double curly loop" - }, - { - "emoji": "ใ€ฝ๏ธ", - "name": "part alternation mark" - }, - { - "emoji": "โœณ๏ธ", - "name": "eight-spoked asterisk" - }, - { - "emoji": "โœด๏ธ", - "name": "eight-pointed star" - }, - { - "emoji": "โ‡๏ธ", - "name": "sparkle" - }, - { - "emoji": "ยฉ๏ธ", - "name": "copyright" - }, - { - "emoji": "ยฎ๏ธ", - "name": "registered" - }, - { - "emoji": "โ„ข๏ธ", - "name": "trade mark" - }, - { - "emoji": "#๏ธโƒฃ", - "name": "keycap #" - }, - { - "emoji": "*๏ธโƒฃ", - "name": "keycap *" - }, - { - "emoji": "0๏ธโƒฃ", - "name": "keycap 0" - }, - { - "emoji": "1๏ธโƒฃ", - "name": "keycap 1" - }, - { - "emoji": "2๏ธโƒฃ", - "name": "keycap 2" - }, - { - "emoji": "3๏ธโƒฃ", - "name": "keycap 3" - }, - { - "emoji": "4๏ธโƒฃ", - "name": "keycap 4" - }, - { - "emoji": "5๏ธโƒฃ", - "name": "keycap 5" - }, - { - "emoji": "6๏ธโƒฃ", - "name": "keycap 6" - }, - { - "emoji": "7๏ธโƒฃ", - "name": "keycap 7" - }, - { - "emoji": "8๏ธโƒฃ", - "name": "keycap 8" - }, - { - "emoji": "9๏ธโƒฃ", - "name": "keycap 9" - }, - { - "emoji": "๐Ÿ”Ÿ", - "name": "keycap 10" - }, - { - "emoji": "๐Ÿ” ", - "name": "input latin uppercase" - }, - { - "emoji": "๐Ÿ”ก", - "name": "input latin lowercase" - }, - { - "emoji": "๐Ÿ”ข", - "name": "input numbers" - }, - { - "emoji": "๐Ÿ”ฃ", - "name": "input symbols" - }, - { - "emoji": "๐Ÿ”ค", - "name": "input latin letters" - }, - { - "emoji": "๐Ÿ…ฐ๏ธ", - "name": "A button (blood type)" - }, - { - "emoji": "๐Ÿ†Ž", - "name": "AB button (blood type)" - }, - { - "emoji": "๐Ÿ…ฑ๏ธ", - "name": "B button (blood type)" - }, - { - "emoji": "๐Ÿ†‘", - "name": "CL button" - }, - { - "emoji": "๐Ÿ†’", - "name": "COOL button" - }, - { - "emoji": "๐Ÿ†“", - "name": "FREE button" - }, - { - "emoji": "โ„น๏ธ", - "name": "information" - }, - { - "emoji": "๐Ÿ†”", - "name": "ID button" - }, - { - "emoji": "โ“‚๏ธ", - "name": "circled M" - }, - { - "emoji": "๐Ÿ†•", - "name": "NEW button" - }, - { - "emoji": "๐Ÿ†–", - "name": "NG button" - }, - { - "emoji": "๐Ÿ…พ๏ธ", - "name": "O button (blood type)" - }, - { - "emoji": "๐Ÿ†—", - "name": "OK button" - }, - { - "emoji": "๐Ÿ…ฟ๏ธ", - "name": "P button" - }, - { - "emoji": "๐Ÿ†˜", - "name": "SOS button" - }, - { - "emoji": "๐Ÿ†™", - "name": "UP! button" - }, - { - "emoji": "๐Ÿ†š", - "name": "VS button" - }, - { - "emoji": "๐Ÿˆ", - "name": "Japanese โ€œhereโ€ button" - }, - { - "emoji": "๐Ÿˆ‚๏ธ", - "name": "Japanese โ€œservice chargeโ€ button" - }, - { - "emoji": "๐Ÿˆท๏ธ", - "name": "Japanese โ€œmonthly amountโ€ button" - }, - { - "emoji": "๐Ÿˆถ", - "name": "Japanese โ€œnot free of chargeโ€ button" - }, - { - "emoji": "๐Ÿˆฏ", - "name": "Japanese โ€œreservedโ€ button" - }, - { - "emoji": "๐Ÿ‰", - "name": "Japanese โ€œbargainโ€ button" - }, - { - "emoji": "๐Ÿˆน", - "name": "Japanese โ€œdiscountโ€ button" - }, - { - "emoji": "๐Ÿˆš", - "name": "Japanese โ€œfree of chargeโ€ button" - }, - { - "emoji": "๐Ÿˆฒ", - "name": "Japanese โ€œprohibitedโ€ button" - }, - { - "emoji": "๐Ÿ‰‘", - "name": "Japanese โ€œacceptableโ€ button" - }, - { - "emoji": "๐Ÿˆธ", - "name": "Japanese โ€œapplicationโ€ button" - }, - { - "emoji": "๐Ÿˆด", - "name": "Japanese โ€œpassing gradeโ€ button" - }, - { - "emoji": "๐Ÿˆณ", - "name": "Japanese โ€œvacancyโ€ button" - }, - { - "emoji": "ใŠ—๏ธ", - "name": "Japanese โ€œcongratulationsโ€ button" - }, - { - "emoji": "ใŠ™๏ธ", - "name": "Japanese โ€œsecretโ€ button" - }, - { - "emoji": "๐Ÿˆบ", - "name": "Japanese โ€œopen for businessโ€ button" - }, - { - "emoji": "๐Ÿˆต", - "name": "Japanese โ€œno vacancyโ€ button" - }, - { - "emoji": "๐Ÿ”ด", - "name": "red circle" - }, - { - "emoji": "๐ŸŸ ", - "name": "orange circle" - }, - { - "emoji": "๐ŸŸก", - "name": "yellow circle" - }, - { - "emoji": "๐ŸŸข", - "name": "green circle" - }, - { - "emoji": "๐Ÿ”ต", - "name": "blue circle" - }, - { - "emoji": "๐ŸŸฃ", - "name": "purple circle" - }, - { - "emoji": "๐ŸŸค", - "name": "brown circle" - }, - { - "emoji": "โšซ", - "name": "black circle" - }, - { - "emoji": "โšช", - "name": "white circle" - }, - { - "emoji": "๐ŸŸฅ", - "name": "red square" - }, - { - "emoji": "๐ŸŸง", - "name": "orange square" - }, - { - "emoji": "๐ŸŸจ", - "name": "yellow square" - }, - { - "emoji": "๐ŸŸฉ", - "name": "green square" - }, - { - "emoji": "๐ŸŸฆ", - "name": "blue square" - }, - { - "emoji": "๐ŸŸช", - "name": "purple square" - }, - { - "emoji": "๐ŸŸซ", - "name": "brown square" - }, - { - "emoji": "โฌ›", - "name": "black large square" - }, - { - "emoji": "โฌœ", - "name": "white large square" - }, - { - "emoji": "โ—ผ๏ธ", - "name": "black medium square" - }, - { - "emoji": "โ—ป๏ธ", - "name": "white medium square" - }, - { - "emoji": "โ—พ", - "name": "black medium-small square" - }, - { - "emoji": "โ—ฝ", - "name": "white medium-small square" - }, - { - "emoji": "โ–ช๏ธ", - "name": "black small square" - }, - { - "emoji": "โ–ซ๏ธ", - "name": "white small square" - }, - { - "emoji": "๐Ÿ”ถ", - "name": "large orange diamond" - }, - { - "emoji": "๐Ÿ”ท", - "name": "large blue diamond" - }, - { - "emoji": "๐Ÿ”ธ", - "name": "small orange diamond" - }, - { - "emoji": "๐Ÿ”น", - "name": "small blue diamond" - }, - { - "emoji": "๐Ÿ”บ", - "name": "red triangle pointed up" - }, - { - "emoji": "๐Ÿ”ป", - "name": "red triangle pointed down" - }, - { - "emoji": "๐Ÿ’ ", - "name": "diamond with a dot" - }, - { - "emoji": "๐Ÿ”˜", - "name": "radio button" - }, - { - "emoji": "๐Ÿ”ณ", - "name": "white square button" - }, - { - "emoji": "๐Ÿ”ฒ", - "name": "black square button" - } - ], - "Flags": [ - { - "emoji": "๐Ÿ", - "name": "chequered flag" - }, - { - "emoji": "๐Ÿšฉ", - "name": "triangular flag" - }, - { - "emoji": "๐ŸŽŒ", - "name": "crossed flags" - }, - { - "emoji": "๐Ÿด", - "name": "black flag" - }, - { - "emoji": "๐Ÿณ๏ธ", - "name": "white flag" - }, - { - "emoji": "๐Ÿณ๏ธโ€๐ŸŒˆ", - "name": "rainbow flag" - }, - { - "emoji": "๐Ÿณ๏ธโ€โšง๏ธ", - "name": "transgender flag" - }, - { - "emoji": "๐Ÿดโ€โ˜ ๏ธ", - "name": "pirate flag" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡จ", - "name": "flag Ascension Island" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฉ", - "name": "flag Andorra" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ช", - "name": "flag United Arab Emirates" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ซ", - "name": "flag Afghanistan" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฌ", - "name": "flag Antigua & Barbuda" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฎ", - "name": "flag Anguilla" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฑ", - "name": "flag Albania" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฒ", - "name": "flag Armenia" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ด", - "name": "flag Angola" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ถ", - "name": "flag Antarctica" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ท", - "name": "flag Argentina" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ธ", - "name": "flag American Samoa" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡น", - "name": "flag Austria" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡บ", - "name": "flag Australia" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ผ", - "name": "flag Aruba" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฝ", - "name": "flag ร…land Islands" - }, - { - "emoji": "๐Ÿ‡ฆ๐Ÿ‡ฟ", - "name": "flag Azerbaijan" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฆ", - "name": "flag Bosnia & Herzegovina" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ง", - "name": "flag Barbados" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฉ", - "name": "flag Bangladesh" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ช", - "name": "flag Belgium" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ซ", - "name": "flag Burkina Faso" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฌ", - "name": "flag Bulgaria" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ญ", - "name": "flag Bahrain" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฎ", - "name": "flag Burundi" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฏ", - "name": "flag Benin" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฑ", - "name": "flag St. Barthรฉlemy" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฒ", - "name": "flag Bermuda" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ณ", - "name": "flag Brunei" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ด", - "name": "flag Bolivia" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ถ", - "name": "flag Caribbean Netherlands" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ท", - "name": "flag Brazil" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ธ", - "name": "flag Bahamas" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡น", - "name": "flag Bhutan" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ป", - "name": "flag Bouvet Island" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ผ", - "name": "flag Botswana" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡พ", - "name": "flag Belarus" - }, - { - "emoji": "๐Ÿ‡ง๐Ÿ‡ฟ", - "name": "flag Belize" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฆ", - "name": "flag Canada" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡จ", - "name": "flag Cocos (Keeling) Islands" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฉ", - "name": "flag Congo - Kinshasa" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ซ", - "name": "flag Central African Republic" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฌ", - "name": "flag Congo - Brazzaville" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ญ", - "name": "flag Switzerland" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฎ", - "name": "flag Cรดte dโ€™Ivoire" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฐ", - "name": "flag Cook Islands" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฑ", - "name": "flag Chile" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฒ", - "name": "flag Cameroon" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ณ", - "name": "flag China" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ด", - "name": "flag Colombia" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ต", - "name": "flag Clipperton Island" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ท", - "name": "flag Costa Rica" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡บ", - "name": "flag Cuba" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ป", - "name": "flag Cape Verde" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ผ", - "name": "flag Curaรงao" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฝ", - "name": "flag Christmas Island" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡พ", - "name": "flag Cyprus" - }, - { - "emoji": "๐Ÿ‡จ๐Ÿ‡ฟ", - "name": "flag Czechia" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ช", - "name": "flag Germany" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ฌ", - "name": "flag Diego Garcia" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ฏ", - "name": "flag Djibouti" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ฐ", - "name": "flag Denmark" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ฒ", - "name": "flag Dominica" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ด", - "name": "flag Dominican Republic" - }, - { - "emoji": "๐Ÿ‡ฉ๐Ÿ‡ฟ", - "name": "flag Algeria" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ฆ", - "name": "flag Ceuta & Melilla" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡จ", - "name": "flag Ecuador" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ช", - "name": "flag Estonia" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ฌ", - "name": "flag Egypt" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ญ", - "name": "flag Western Sahara" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ท", - "name": "flag Eritrea" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡ธ", - "name": "flag Spain" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡น", - "name": "flag Ethiopia" - }, - { - "emoji": "๐Ÿ‡ช๐Ÿ‡บ", - "name": "flag European Union" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ฎ", - "name": "flag Finland" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ฏ", - "name": "flag Fiji" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ฐ", - "name": "flag Falkland Islands" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ฒ", - "name": "flag Micronesia" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ด", - "name": "flag Faroe Islands" - }, - { - "emoji": "๐Ÿ‡ซ๐Ÿ‡ท", - "name": "flag France" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฆ", - "name": "flag Gabon" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ง", - "name": "flag United Kingdom" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฉ", - "name": "flag Grenada" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ช", - "name": "flag Georgia" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ซ", - "name": "flag French Guiana" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฌ", - "name": "flag Guernsey" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ญ", - "name": "flag Ghana" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฎ", - "name": "flag Gibraltar" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฑ", - "name": "flag Greenland" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ฒ", - "name": "flag Gambia" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ณ", - "name": "flag Guinea" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ต", - "name": "flag Guadeloupe" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ถ", - "name": "flag Equatorial Guinea" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ท", - "name": "flag Greece" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ธ", - "name": "flag South Georgia & South Sandwich Islands" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡น", - "name": "flag Guatemala" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡บ", - "name": "flag Guam" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡ผ", - "name": "flag Guinea-Bissau" - }, - { - "emoji": "๐Ÿ‡ฌ๐Ÿ‡พ", - "name": "flag Guyana" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡ฐ", - "name": "flag Hong Kong SAR China" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡ฒ", - "name": "flag Heard & McDonald Islands" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡ณ", - "name": "flag Honduras" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡ท", - "name": "flag Croatia" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡น", - "name": "flag Haiti" - }, - { - "emoji": "๐Ÿ‡ญ๐Ÿ‡บ", - "name": "flag Hungary" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡จ", - "name": "flag Canary Islands" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ฉ", - "name": "flag Indonesia" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ช", - "name": "flag Ireland" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ฑ", - "name": "flag Israel" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ฒ", - "name": "flag Isle of Man" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ณ", - "name": "flag India" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ด", - "name": "flag British Indian Ocean Territory" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ถ", - "name": "flag Iraq" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ท", - "name": "flag Iran" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡ธ", - "name": "flag Iceland" - }, - { - "emoji": "๐Ÿ‡ฎ๐Ÿ‡น", - "name": "flag Italy" - }, - { - "emoji": "๐Ÿ‡ฏ๐Ÿ‡ช", - "name": "flag Jersey" - }, - { - "emoji": "๐Ÿ‡ฏ๐Ÿ‡ฒ", - "name": "flag Jamaica" - }, - { - "emoji": "๐Ÿ‡ฏ๐Ÿ‡ด", - "name": "flag Jordan" - }, - { - "emoji": "๐Ÿ‡ฏ๐Ÿ‡ต", - "name": "flag Japan" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ช", - "name": "flag Kenya" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ฌ", - "name": "flag Kyrgyzstan" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ญ", - "name": "flag Cambodia" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ฎ", - "name": "flag Kiribati" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ฒ", - "name": "flag Comoros" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ณ", - "name": "flag St. Kitts & Nevis" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ต", - "name": "flag North Korea" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ท", - "name": "flag South Korea" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ผ", - "name": "flag Kuwait" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡พ", - "name": "flag Cayman Islands" - }, - { - "emoji": "๐Ÿ‡ฐ๐Ÿ‡ฟ", - "name": "flag Kazakhstan" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ฆ", - "name": "flag Laos" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ง", - "name": "flag Lebanon" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡จ", - "name": "flag St. Lucia" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ฎ", - "name": "flag Liechtenstein" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ฐ", - "name": "flag Sri Lanka" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ท", - "name": "flag Liberia" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ธ", - "name": "flag Lesotho" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡น", - "name": "flag Lithuania" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡บ", - "name": "flag Luxembourg" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡ป", - "name": "flag Latvia" - }, - { - "emoji": "๐Ÿ‡ฑ๐Ÿ‡พ", - "name": "flag Libya" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฆ", - "name": "flag Morocco" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡จ", - "name": "flag Monaco" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฉ", - "name": "flag Moldova" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ช", - "name": "flag Montenegro" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ซ", - "name": "flag St. Martin" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฌ", - "name": "flag Madagascar" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ญ", - "name": "flag Marshall Islands" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฐ", - "name": "flag North Macedonia" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฑ", - "name": "flag Mali" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฒ", - "name": "flag Myanmar (Burma)" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ณ", - "name": "flag Mongolia" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ด", - "name": "flag Macao SAR China" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ต", - "name": "flag Northern Mariana Islands" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ถ", - "name": "flag Martinique" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ท", - "name": "flag Mauritania" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ธ", - "name": "flag Montserrat" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡น", - "name": "flag Malta" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡บ", - "name": "flag Mauritius" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ป", - "name": "flag Maldives" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ผ", - "name": "flag Malawi" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฝ", - "name": "flag Mexico" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡พ", - "name": "flag Malaysia" - }, - { - "emoji": "๐Ÿ‡ฒ๐Ÿ‡ฟ", - "name": "flag Mozambique" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ฆ", - "name": "flag Namibia" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡จ", - "name": "flag New Caledonia" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ช", - "name": "flag Niger" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ซ", - "name": "flag Norfolk Island" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ฌ", - "name": "flag Nigeria" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ฎ", - "name": "flag Nicaragua" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ฑ", - "name": "flag Netherlands" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ด", - "name": "flag Norway" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ต", - "name": "flag Nepal" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ท", - "name": "flag Nauru" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡บ", - "name": "flag Niue" - }, - { - "emoji": "๐Ÿ‡ณ๐Ÿ‡ฟ", - "name": "flag New Zealand" - }, - { - "emoji": "๐Ÿ‡ด๐Ÿ‡ฒ", - "name": "flag Oman" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ฆ", - "name": "flag Panama" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ช", - "name": "flag Peru" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ซ", - "name": "flag French Polynesia" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ฌ", - "name": "flag Papua New Guinea" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ญ", - "name": "flag Philippines" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ฐ", - "name": "flag Pakistan" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ฑ", - "name": "flag Poland" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ฒ", - "name": "flag St. Pierre & Miquelon" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ณ", - "name": "flag Pitcairn Islands" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ท", - "name": "flag Puerto Rico" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ธ", - "name": "flag Palestinian Territories" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡น", - "name": "flag Portugal" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡ผ", - "name": "flag Palau" - }, - { - "emoji": "๐Ÿ‡ต๐Ÿ‡พ", - "name": "flag Paraguay" - }, - { - "emoji": "๐Ÿ‡ถ๐Ÿ‡ฆ", - "name": "flag Qatar" - }, - { - "emoji": "๐Ÿ‡ท๐Ÿ‡ช", - "name": "flag Rรฉunion" - }, - { - "emoji": "๐Ÿ‡ท๐Ÿ‡ด", - "name": "flag Romania" - }, - { - "emoji": "๐Ÿ‡ท๐Ÿ‡ธ", - "name": "flag Serbia" - }, - { - "emoji": "๐Ÿ‡ท๐Ÿ‡บ", - "name": "flag Russia" - }, - { - "emoji": "๐Ÿ‡ท๐Ÿ‡ผ", - "name": "flag Rwanda" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฆ", - "name": "flag Saudi Arabia" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ง", - "name": "flag Solomon Islands" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡จ", - "name": "flag Seychelles" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฉ", - "name": "flag Sudan" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ช", - "name": "flag Sweden" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฌ", - "name": "flag Singapore" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ญ", - "name": "flag St. Helena" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฎ", - "name": "flag Slovenia" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฏ", - "name": "flag Svalbard & Jan Mayen" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฐ", - "name": "flag Slovakia" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฑ", - "name": "flag Sierra Leone" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฒ", - "name": "flag San Marino" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ณ", - "name": "flag Senegal" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ด", - "name": "flag Somalia" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ท", - "name": "flag Suriname" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ธ", - "name": "flag South Sudan" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡น", - "name": "flag Sรฃo Tomรฉ & Prรญncipe" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ป", - "name": "flag El Salvador" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฝ", - "name": "flag Sint Maarten" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡พ", - "name": "flag Syria" - }, - { - "emoji": "๐Ÿ‡ธ๐Ÿ‡ฟ", - "name": "flag Eswatini" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฆ", - "name": "flag Tristan da Cunha" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡จ", - "name": "flag Turks & Caicos Islands" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฉ", - "name": "flag Chad" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ซ", - "name": "flag French Southern Territories" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฌ", - "name": "flag Togo" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ญ", - "name": "flag Thailand" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฏ", - "name": "flag Tajikistan" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฐ", - "name": "flag Tokelau" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฑ", - "name": "flag Timor-Leste" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฒ", - "name": "flag Turkmenistan" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ณ", - "name": "flag Tunisia" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ด", - "name": "flag Tonga" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ท", - "name": "flag Tรผrkiye" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡น", - "name": "flag Trinidad & Tobago" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ป", - "name": "flag Tuvalu" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ผ", - "name": "flag Taiwan" - }, - { - "emoji": "๐Ÿ‡น๐Ÿ‡ฟ", - "name": "flag Tanzania" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ฆ", - "name": "flag Ukraine" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ฌ", - "name": "flag Uganda" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ฒ", - "name": "flag U.S. Outlying Islands" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ณ", - "name": "flag United Nations" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ธ", - "name": "flag United States" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡พ", - "name": "flag Uruguay" - }, - { - "emoji": "๐Ÿ‡บ๐Ÿ‡ฟ", - "name": "flag Uzbekistan" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡ฆ", - "name": "flag Vatican City" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡จ", - "name": "flag St. Vincent & Grenadines" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡ช", - "name": "flag Venezuela" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡ฌ", - "name": "flag British Virgin Islands" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡ฎ", - "name": "flag U.S. Virgin Islands" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡ณ", - "name": "flag Vietnam" - }, - { - "emoji": "๐Ÿ‡ป๐Ÿ‡บ", - "name": "flag Vanuatu" - }, - { - "emoji": "๐Ÿ‡ผ๐Ÿ‡ซ", - "name": "flag Wallis & Futuna" - }, - { - "emoji": "๐Ÿ‡ผ๐Ÿ‡ธ", - "name": "flag Samoa" - }, - { - "emoji": "๐Ÿ‡ฝ๐Ÿ‡ฐ", - "name": "flag Kosovo" - }, - { - "emoji": "๐Ÿ‡พ๐Ÿ‡ช", - "name": "flag Yemen" - }, - { - "emoji": "๐Ÿ‡พ๐Ÿ‡น", - "name": "flag Mayotte" - }, - { - "emoji": "๐Ÿ‡ฟ๐Ÿ‡ฆ", - "name": "flag South Africa" - }, - { - "emoji": "๐Ÿ‡ฟ๐Ÿ‡ฒ", - "name": "flag Zambia" - }, - { - "emoji": "๐Ÿ‡ฟ๐Ÿ‡ผ", - "name": "flag Zimbabwe" - }, - { - "emoji": "๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ", - "name": "flag England" - }, - { - "emoji": "๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ", - "name": "flag Scotland" - }, - { - "emoji": "๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ", - "name": "flag Wales" - } - ] -} \ No newline at end of file diff --git a/src/@angor/angor.provider.ts b/src/@angor/angor.provider.ts index 27e6a582..e78028b6 100644 --- a/src/@angor/angor.provider.ts +++ b/src/@angor/angor.provider.ts @@ -1,15 +1,3 @@ -import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { - APP_INITIALIZER, - ENVIRONMENT_INITIALIZER, - EnvironmentProviders, - Provider, - importProvidersFrom, - inject, -} from '@angular/core'; -import { MATERIAL_SANITY_CHECKS } from '@angular/material/core'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { ANGOR_MOCK_API_DEFAULT_DELAY, mockApiInterceptor, @@ -25,6 +13,18 @@ import { AngorMediaWatcherService } from '@angor/services/media-watcher'; import { AngorPlatformService } from '@angor/services/platform'; import { AngorSplashScreenService } from '@angor/services/splash-screen'; import { AngorUtilsService } from '@angor/services/utils'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { + APP_INITIALIZER, + ENVIRONMENT_INITIALIZER, + EnvironmentProviders, + Provider, + importProvidersFrom, + inject, +} from '@angular/core'; +import { MATERIAL_SANITY_CHECKS } from '@angular/material/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; export type AngorProviderConfig = { mockApi?: { @@ -40,10 +40,8 @@ export type AngorProviderConfig = { export const provideAngor = ( config: AngorProviderConfig ): Array => { - const providers: Array = [ { - provide: MATERIAL_SANITY_CHECKS, useValue: { doctype: true, @@ -52,7 +50,6 @@ export const provideAngor = ( }, }, { - provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'fill', @@ -103,7 +100,6 @@ export const provideAngor = ( }, ]; - if (config?.mockApi?.services) { providers.push( provideHttpClient(withInterceptors([mockApiInterceptor])), @@ -116,6 +112,5 @@ export const provideAngor = ( ); } - return providers; }; diff --git a/src/@angor/animations/defaults.ts b/src/@angor/animations/defaults.ts index f08a5519..3cd6c412 100644 --- a/src/@angor/animations/defaults.ts +++ b/src/@angor/animations/defaults.ts @@ -2,17 +2,17 @@ * Defines animation curves for Angor. */ export class AngorAnimationCurves { - static standard = 'cubic-bezier(0.4, 0.0, 0.2, 1)'; // Standard animation curve - static deceleration = 'cubic-bezier(0.0, 0.0, 0.2, 1)'; // Deceleration curve - static acceleration = 'cubic-bezier(0.4, 0.0, 1, 1)'; // Acceleration curve - static sharp = 'cubic-bezier(0.4, 0.0, 0.6, 1)'; // Sharp curve + static standard = 'cubic-bezier(0.4, 0.0, 0.2, 1)'; // Standard animation curve + static deceleration = 'cubic-bezier(0.0, 0.0, 0.2, 1)'; // Deceleration curve + static acceleration = 'cubic-bezier(0.4, 0.0, 1, 1)'; // Acceleration curve + static sharp = 'cubic-bezier(0.4, 0.0, 0.6, 1)'; // Sharp curve } /** * Defines animation durations for Angor. */ export class AngorAnimationDurations { - static complex = '375ms'; // Duration for complex animations - static entering = '225ms'; // Duration for entering animations - static exiting = '195ms'; // Duration for exiting animations + static complex = '375ms'; // Duration for complex animations + static entering = '225ms'; // Duration for entering animations + static exiting = '195ms'; // Duration for exiting animations } diff --git a/src/@angor/animations/expand-collapse.ts b/src/@angor/animations/expand-collapse.ts index 15bf1eb0..2d1b731d 100644 --- a/src/@angor/animations/expand-collapse.ts +++ b/src/@angor/animations/expand-collapse.ts @@ -1,3 +1,7 @@ +import { + AngorAnimationCurves, + AngorAnimationDurations, +} from '@angor/animations/defaults'; import { animate, state, @@ -5,10 +9,6 @@ import { transition, trigger, } from '@angular/animations'; -import { - AngorAnimationCurves, - AngorAnimationDurations, -} from '@angor/animations/defaults'; /** * Animation trigger for expand/collapse transitions diff --git a/src/@angor/animations/fade.ts b/src/@angor/animations/fade.ts index 8813db4a..f2539ac0 100644 --- a/src/@angor/animations/fade.ts +++ b/src/@angor/animations/fade.ts @@ -1,3 +1,7 @@ +import { + AngorAnimationCurves, + AngorAnimationDurations, +} from '@angor/animations/defaults'; import { animate, state, @@ -5,10 +9,6 @@ import { transition, trigger, } from '@angular/animations'; -import { - AngorAnimationCurves, - AngorAnimationDurations, -} from '@angor/animations/defaults'; /** * Fade in animation trigger diff --git a/src/@angor/animations/shake.ts b/src/@angor/animations/shake.ts index a0c2d889..0b5e56eb 100644 --- a/src/@angor/animations/shake.ts +++ b/src/@angor/animations/shake.ts @@ -21,15 +21,42 @@ const shake = trigger('shake', [ '{{timings}}', keyframes([ style({ transform: 'translate3d(0, 0, 0)', offset: 0 }), - style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.1 }), - style({ transform: 'translate3d(10px, 0, 0)', offset: 0.2 }), - style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.3 }), - style({ transform: 'translate3d(10px, 0, 0)', offset: 0.4 }), - style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.5 }), - style({ transform: 'translate3d(10px, 0, 0)', offset: 0.6 }), - style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.7 }), - style({ transform: 'translate3d(10px, 0, 0)', offset: 0.8 }), - style({ transform: 'translate3d(-10px, 0, 0)', offset: 0.9 }), + style({ + transform: 'translate3d(-10px, 0, 0)', + offset: 0.1, + }), + style({ + transform: 'translate3d(10px, 0, 0)', + offset: 0.2, + }), + style({ + transform: 'translate3d(-10px, 0, 0)', + offset: 0.3, + }), + style({ + transform: 'translate3d(10px, 0, 0)', + offset: 0.4, + }), + style({ + transform: 'translate3d(-10px, 0, 0)', + offset: 0.5, + }), + style({ + transform: 'translate3d(10px, 0, 0)', + offset: 0.6, + }), + style({ + transform: 'translate3d(-10px, 0, 0)', + offset: 0.7, + }), + style({ + transform: 'translate3d(10px, 0, 0)', + offset: 0.8, + }), + style({ + transform: 'translate3d(-10px, 0, 0)', + offset: 0.9, + }), style({ transform: 'translate3d(0, 0, 0)', offset: 1 }), ]) ), diff --git a/src/@angor/animations/slide.ts b/src/@angor/animations/slide.ts index 57b62226..da2fa898 100644 --- a/src/@angor/animations/slide.ts +++ b/src/@angor/animations/slide.ts @@ -1,3 +1,7 @@ +import { + AngorAnimationCurves, + AngorAnimationDurations, +} from '@angor/animations/defaults'; import { animate, state, @@ -5,10 +9,6 @@ import { transition, trigger, } from '@angular/animations'; -import { - AngorAnimationCurves, - AngorAnimationDurations, -} from '@angor/animations/defaults'; /** * Slide in from top animation trigger diff --git a/src/@angor/animations/zoom.ts b/src/@angor/animations/zoom.ts index 1775f975..0203bddd 100644 --- a/src/@angor/animations/zoom.ts +++ b/src/@angor/animations/zoom.ts @@ -1,3 +1,7 @@ +import { + AngorAnimationCurves, + AngorAnimationDurations, +} from '@angor/animations/defaults'; import { animate, state, @@ -5,10 +9,6 @@ import { transition, trigger, } from '@angular/animations'; -import { - AngorAnimationCurves, - AngorAnimationDurations, -} from '@angor/animations/defaults'; /** * Creates a reusable animation trigger with configurable parameters. diff --git a/src/@angor/components/alert/alert.component.ts b/src/@angor/components/alert/alert.component.ts index ea9ebece..a008e1d1 100644 --- a/src/@angor/components/alert/alert.component.ts +++ b/src/@angor/components/alert/alert.component.ts @@ -1,3 +1,10 @@ +import { angorAnimations } from '@angor/animations'; +import { AngorAlertService } from '@angor/components/alert/alert.service'; +import { + AngorAlertAppearance, + AngorAlertType, +} from '@angor/components/alert/alert.types'; +import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { ChangeDetectionStrategy, @@ -16,13 +23,6 @@ import { } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; -import { angorAnimations } from '@angor/animations'; -import { AngorAlertService } from '@angor/components/alert/alert.service'; -import { - AngorAlertAppearance, - AngorAlertType, -} from '@angor/components/alert/alert.types'; -import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { Subject, filter, takeUntil } from 'rxjs'; @Component({ @@ -86,16 +86,22 @@ export class AngorAlertComponent implements OnChanges, OnInit, OnDestroy { */ ngOnChanges(changes: SimpleChanges): void { if ('dismissed' in changes) { - this.dismissed = coerceBooleanProperty(changes.dismissed.currentValue); + this.dismissed = coerceBooleanProperty( + changes.dismissed.currentValue + ); this._toggleDismiss(this.dismissed); } if ('dismissible' in changes) { - this.dismissible = coerceBooleanProperty(changes.dismissible.currentValue); + this.dismissible = coerceBooleanProperty( + changes.dismissible.currentValue + ); } if ('showIcon' in changes) { - this.showIcon = coerceBooleanProperty(changes.showIcon.currentValue); + this.showIcon = coerceBooleanProperty( + changes.showIcon.currentValue + ); } } diff --git a/src/@angor/components/card/card.component.ts b/src/@angor/components/card/card.component.ts index 9d981bed..99e43f8e 100644 --- a/src/@angor/components/card/card.component.ts +++ b/src/@angor/components/card/card.component.ts @@ -1,3 +1,5 @@ +import { angorAnimations } from '@angor/animations'; +import { AngorCardFace } from '@angor/components/card/card.types'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { Component, @@ -7,8 +9,6 @@ import { SimpleChanges, ViewEncapsulation, } from '@angular/core'; -import { angorAnimations } from '@angor/animations'; -import { AngorCardFace } from '@angor/components/card/card.types'; @Component({ selector: 'angor-card', @@ -47,11 +47,15 @@ export class AngorCardComponent implements OnChanges { */ ngOnChanges(changes: SimpleChanges): void { if ('expanded' in changes) { - this.expanded = coerceBooleanProperty(changes.expanded.currentValue); + this.expanded = coerceBooleanProperty( + changes.expanded.currentValue + ); } if ('flippable' in changes) { - this.flippable = coerceBooleanProperty(changes.flippable.currentValue); + this.flippable = coerceBooleanProperty( + changes.flippable.currentValue + ); } } } diff --git a/src/@angor/components/drawer/drawer.component.ts b/src/@angor/components/drawer/drawer.component.ts index 9eea79f4..50aa7faf 100644 --- a/src/@angor/components/drawer/drawer.component.ts +++ b/src/@angor/components/drawer/drawer.component.ts @@ -1,3 +1,9 @@ +import { AngorDrawerService } from '@angor/components/drawer/drawer.service'; +import { + AngorDrawerMode, + AngorDrawerPosition, +} from '@angor/components/drawer/drawer.types'; +import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { animate, AnimationBuilder, @@ -11,6 +17,7 @@ import { EventEmitter, HostBinding, HostListener, + inject, Input, OnChanges, OnDestroy, @@ -19,14 +26,7 @@ import { Renderer2, SimpleChanges, ViewEncapsulation, - inject, } from '@angular/core'; -import { AngorDrawerService } from '@angor/components/drawer/drawer.service'; -import { - AngorDrawerMode, - AngorDrawerPosition, -} from '@angor/components/drawer/drawer.types'; -import { AngorUtilsService } from '@angor/services/utils/utils.service'; @Component({ selector: 'angor-drawer', @@ -56,7 +56,8 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy { @Output() readonly fixedChanged = new EventEmitter(); @Output() readonly modeChanged = new EventEmitter(); @Output() readonly openedChanged = new EventEmitter(); - @Output() readonly positionChanged = new EventEmitter(); + @Output() readonly positionChanged = + new EventEmitter(); private _animationsEnabled: boolean = false; private _hovered: boolean = false; @@ -107,7 +108,11 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy { this._hideOverlay(); } - if (previousMode === 'side' && currentMode === 'over' && this.opened) { + if ( + previousMode === 'side' && + currentMode === 'over' && + this.opened + ) { this._showOverlay(); } @@ -125,7 +130,9 @@ export class AngorDrawerComponent implements OnChanges, OnInit, OnDestroy { } if ('transparentOverlay' in changes) { - this.transparentOverlay = coerceBooleanProperty(changes.transparentOverlay.currentValue); + this.transparentOverlay = coerceBooleanProperty( + changes.transparentOverlay.currentValue + ); } } diff --git a/src/@angor/components/drawer/drawer.service.ts b/src/@angor/components/drawer/drawer.service.ts index daa219b7..a059da58 100644 --- a/src/@angor/components/drawer/drawer.service.ts +++ b/src/@angor/components/drawer/drawer.service.ts @@ -1,5 +1,5 @@ -import { Injectable } from '@angular/core'; import { AngorDrawerComponent } from '@angor/components/drawer/drawer.component'; +import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class AngorDrawerService { diff --git a/src/@angor/components/highlight/highlight.component.ts b/src/@angor/components/highlight/highlight.component.ts index 0bf3b279..8aca97b9 100644 --- a/src/@angor/components/highlight/highlight.component.ts +++ b/src/@angor/components/highlight/highlight.component.ts @@ -1,3 +1,4 @@ +import { AngorHighlightService } from '@angor/components/highlight/highlight.service'; import { NgClass } from '@angular/common'; import { AfterViewInit, @@ -16,7 +17,6 @@ import { ViewEncapsulation, } from '@angular/core'; import { DomSanitizer } from '@angular/platform-browser'; -import { AngorHighlightService } from '@angor/components/highlight/highlight.service'; @Component({ selector: 'textarea[angor-highlight]', diff --git a/src/@angor/components/highlight/highlight.service.ts b/src/@angor/components/highlight/highlight.service.ts index ddd60288..d1855f68 100644 --- a/src/@angor/components/highlight/highlight.service.ts +++ b/src/@angor/components/highlight/highlight.service.ts @@ -3,7 +3,6 @@ import hljs from 'highlight.js'; @Injectable({ providedIn: 'root' }) export class AngorHighlightService { - /** * Highlights the provided code using the specified language. */ @@ -28,15 +27,17 @@ export class AngorHighlightService { } // Determine the smallest indentation - lines.filter(line => line.length).forEach((line, index) => { - if (index === 0) { - indentation = line.search(/\S|$/); - } else { - indentation = Math.min(line.search(/\S|$/), indentation); - } - }); + lines + .filter((line) => line.length) + .forEach((line, index) => { + if (index === 0) { + indentation = line.search(/\S|$/); + } else { + indentation = Math.min(line.search(/\S|$/), indentation); + } + }); // Remove extra indentation and return formatted code - return lines.map(line => line.substring(indentation)).join('\n'); + return lines.map((line) => line.substring(indentation)).join('\n'); } } diff --git a/src/@angor/components/loading-bar/loading-bar.component.ts b/src/@angor/components/loading-bar/loading-bar.component.ts index 19ca3ceb..6bb806df 100644 --- a/src/@angor/components/loading-bar/loading-bar.component.ts +++ b/src/@angor/components/loading-bar/loading-bar.component.ts @@ -1,7 +1,16 @@ +import { AngorLoadingService } from '@angor/services/loading'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { Component, inject, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewEncapsulation } from '@angular/core'; +import { + Component, + inject, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewEncapsulation, +} from '@angular/core'; import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { AngorLoadingService } from '@angor/services/loading'; import { Subject, takeUntil } from 'rxjs'; @Component({ diff --git a/src/@angor/components/masonry/masonry.component.ts b/src/@angor/components/masonry/masonry.component.ts index f3a99cdf..6b60e561 100644 --- a/src/@angor/components/masonry/masonry.component.ts +++ b/src/@angor/components/masonry/masonry.component.ts @@ -1,3 +1,4 @@ +import { angorAnimations } from '@angor/animations'; import { NgTemplateOutlet } from '@angular/common'; import { AfterViewInit, @@ -8,7 +9,6 @@ import { TemplateRef, ViewEncapsulation, } from '@angular/core'; -import { angorAnimations } from '@angor/animations'; @Component({ selector: 'angor-masonry', diff --git a/src/@angor/components/navigation/horizontal/components/basic/basic.component.ts b/src/@angor/components/navigation/horizontal/components/basic/basic.component.ts index c92fad13..cda7d596 100644 --- a/src/@angor/components/navigation/horizontal/components/basic/basic.component.ts +++ b/src/@angor/components/navigation/horizontal/components/basic/basic.component.ts @@ -1,3 +1,7 @@ +import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component'; +import { AngorNavigationService } from '@angor/components/navigation/navigation.service'; +import { AngorNavigationItem } from '@angor/components/navigation/navigation.types'; +import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { NgClass, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectionStrategy, @@ -16,10 +20,6 @@ import { RouterLink, RouterLinkActive, } from '@angular/router'; -import { AngorHorizontalNavigationComponent } from '@angor/components/navigation/horizontal/horizontal.component'; -import { AngorNavigationService } from '@angor/components/navigation/navigation.service'; -import { AngorNavigationItem } from '@angor/components/navigation/navigation.types'; -import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { Subject, takeUntil } from 'rxjs'; @Component({ @@ -69,7 +69,7 @@ export class AngorHorizontalNavigationBasicItemComponent // "isActiveMatchOptions" or the equivalent form of // item's "exactMatch" option this.isActiveMatchOptions = - this.item.isActiveMatchOptions ?? this.item.exactMatch + (this.item.isActiveMatchOptions ?? this.item.exactMatch) ? this._angorUtilsService.exactMatchOptions : this._angorUtilsService.subsetMatchOptions; diff --git a/src/@angor/components/navigation/horizontal/components/branch/branch.component.html b/src/@angor/components/navigation/horizontal/components/branch/branch.component.html index 2dc57971..b6dc8264 100644 --- a/src/@angor/components/navigation/horizontal/components/branch/branch.component.html +++ b/src/@angor/components/navigation/horizontal/components/branch/branch.component.html @@ -66,7 +66,10 @@ @if (item.type === 'divider') { -
+
diff --git a/src/@angor/components/navigation/vertical/vertical.component.ts b/src/@angor/components/navigation/vertical/vertical.component.ts index 47e06fe3..236f06a7 100644 --- a/src/@angor/components/navigation/vertical/vertical.component.ts +++ b/src/@angor/components/navigation/vertical/vertical.component.ts @@ -1,3 +1,19 @@ +import { angorAnimations } from '@angor/animations'; +import { AngorNavigationService } from '@angor/components/navigation/navigation.service'; +import { + AngorNavigationItem, + AngorVerticalNavigationAppearance, + AngorVerticalNavigationMode, + AngorVerticalNavigationPosition, +} from '@angor/components/navigation/navigation.types'; +import { AngorVerticalNavigationAsideItemComponent } from '@angor/components/navigation/vertical/components/aside/aside.component'; +import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component'; +import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component'; +import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component'; +import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component'; +import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component'; +import { AngorScrollbarDirective } from '@angor/directives/scrollbar/scrollbar.directive'; +import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { animate, AnimationBuilder, @@ -30,22 +46,6 @@ import { ViewEncapsulation, } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; -import { angorAnimations } from '@angor/animations'; -import { AngorNavigationService } from '@angor/components/navigation/navigation.service'; -import { - AngorNavigationItem, - AngorVerticalNavigationAppearance, - AngorVerticalNavigationMode, - AngorVerticalNavigationPosition, -} from '@angor/components/navigation/navigation.types'; -import { AngorVerticalNavigationAsideItemComponent } from '@angor/components/navigation/vertical/components/aside/aside.component'; -import { AngorVerticalNavigationBasicItemComponent } from '@angor/components/navigation/vertical/components/basic/basic.component'; -import { AngorVerticalNavigationCollapsableItemComponent } from '@angor/components/navigation/vertical/components/collapsable/collapsable.component'; -import { AngorVerticalNavigationDividerItemComponent } from '@angor/components/navigation/vertical/components/divider/divider.component'; -import { AngorVerticalNavigationGroupItemComponent } from '@angor/components/navigation/vertical/components/group/group.component'; -import { AngorVerticalNavigationSpacerItemComponent } from '@angor/components/navigation/vertical/components/spacer/spacer.component'; -import { AngorScrollbarDirective } from '@angor/directives/scrollbar/scrollbar.directive'; -import { AngorUtilsService } from '@angor/services/utils/utils.service'; import { delay, filter, diff --git a/src/@angor/directives/scroll-reset/scroll-reset.directive.ts b/src/@angor/directives/scroll-reset/scroll-reset.directive.ts index ba914856..732bf348 100644 --- a/src/@angor/directives/scroll-reset/scroll-reset.directive.ts +++ b/src/@angor/directives/scroll-reset/scroll-reset.directive.ts @@ -21,7 +21,7 @@ export class AngorScrollResetDirective implements OnInit, OnDestroy { ngOnInit(): void { this._router.events .pipe( - filter(event => event instanceof NavigationEnd), + filter((event) => event instanceof NavigationEnd), takeUntil(this._unsubscribeAll) ) .subscribe(() => { diff --git a/src/@angor/directives/scrollbar/scrollbar.directive.ts b/src/@angor/directives/scrollbar/scrollbar.directive.ts index 4792f838..c1c71180 100644 --- a/src/@angor/directives/scrollbar/scrollbar.directive.ts +++ b/src/@angor/directives/scrollbar/scrollbar.directive.ts @@ -1,3 +1,7 @@ +import { + ScrollbarGeometry, + ScrollbarPosition, +} from '@angor/directives/scrollbar/scrollbar.types'; import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion'; import { Platform } from '@angular/cdk/platform'; import { @@ -10,10 +14,6 @@ import { SimpleChanges, inject, } from '@angular/core'; -import { - ScrollbarGeometry, - ScrollbarPosition, -} from '@angor/directives/scrollbar/scrollbar.types'; import { merge } from 'lodash-es'; import PerfectScrollbar from 'perfect-scrollbar'; import { Subject, debounceTime, fromEvent, takeUntil } from 'rxjs'; @@ -47,12 +47,20 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { ngOnChanges(changes: SimpleChanges): void { if ('angorScrollbar' in changes) { - this.angorScrollbar = coerceBooleanProperty(changes.angorScrollbar.currentValue); - this.angorScrollbar ? this._initScrollbar() : this._destroyScrollbar(); + this.angorScrollbar = coerceBooleanProperty( + changes.angorScrollbar.currentValue + ); + this.angorScrollbar + ? this._initScrollbar() + : this._destroyScrollbar(); } if ('angorScrollbarOptions' in changes) { - this._options = merge({}, this._options, changes.angorScrollbarOptions.currentValue); + this._options = merge( + {}, + this._options, + changes.angorScrollbarOptions.currentValue + ); this._reinitializeScrollbar(); } } @@ -92,7 +100,10 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { position(absolute: boolean = false): ScrollbarPosition { if (!absolute && this._ps) { - return new ScrollbarPosition(this._ps.reach.x || 0, this._ps.reach.y || 0); + return new ScrollbarPosition( + this._ps.reach.x || 0, + this._ps.reach.y || 0 + ); } else { return new ScrollbarPosition( this._elementRef.nativeElement.scrollLeft, @@ -115,7 +126,6 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { } } - scrollToX(x: number, speed?: number): void { this.animateScrolling('scrollLeft', x, speed); } @@ -129,7 +139,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { } scrollToBottom(offset: number = 0, speed?: number): void { - const top = this._elementRef.nativeElement.scrollHeight - this._elementRef.nativeElement.clientHeight; + const top = + this._elementRef.nativeElement.scrollHeight - + this._elementRef.nativeElement.clientHeight; this.animateScrolling('scrollTop', top - offset, speed); } @@ -138,7 +150,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { } scrollToRight(offset: number = 0, speed?: number): void { - const left = this._elementRef.nativeElement.scrollWidth - this._elementRef.nativeElement.clientWidth; + const left = + this._elementRef.nativeElement.scrollWidth - + this._elementRef.nativeElement.clientWidth; this.animateScrolling('scrollLeft', left - offset, speed); } @@ -152,14 +166,29 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { if (!element) return; const elementPos = element.getBoundingClientRect(); - const scrollerPos = this._elementRef.nativeElement.getBoundingClientRect(); + const scrollerPos = + this._elementRef.nativeElement.getBoundingClientRect(); if (this._elementRef.nativeElement.classList.contains('ps--active-x')) { - this._scrollToInAxis(elementPos.left, scrollerPos.left, 'scrollLeft', offset, ignoreVisible, speed); + this._scrollToInAxis( + elementPos.left, + scrollerPos.left, + 'scrollLeft', + offset, + ignoreVisible, + speed + ); } if (this._elementRef.nativeElement.classList.contains('ps--active-y')) { - this._scrollToInAxis(elementPos.top, scrollerPos.top, 'scrollTop', offset, ignoreVisible, speed); + this._scrollToInAxis( + elementPos.top, + scrollerPos.top, + 'scrollTop', + offset, + ignoreVisible, + speed + ); } } @@ -176,8 +205,16 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { } private _initScrollbar(): void { - if (this._ps || this._platform.ANDROID || this._platform.IOS || !this._platform.isBrowser) return; - this._ps = new PerfectScrollbar(this._elementRef.nativeElement, { ...this._options }); + if ( + this._ps || + this._platform.ANDROID || + this._platform.IOS || + !this._platform.isBrowser + ) + return; + this._ps = new PerfectScrollbar(this._elementRef.nativeElement, { + ...this._options, + }); } private _destroyScrollbar(): void { @@ -198,7 +235,8 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { ignoreVisible: boolean, speed?: number ): void { - if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset)) return; + if (ignoreVisible && elementPos <= scrollerPos - Math.abs(offset)) + return; const currentPos = this._elementRef.nativeElement[target]; const position = elementPos - scrollerPos + currentPos; @@ -213,7 +251,9 @@ export class AngorScrollbarDirective implements OnChanges, OnInit, OnDestroy { const step = (newTimestamp: number) => { scrollCount += Math.PI / (speed / (newTimestamp - oldTimestamp)); - const newValue = Math.round(value + cosParameter + cosParameter * Math.cos(scrollCount)); + const newValue = Math.round( + value + cosParameter + cosParameter * Math.cos(scrollCount) + ); if (this._elementRef.nativeElement[target] === oldValue) { if (scrollCount >= Math.PI) { diff --git a/src/@angor/lib/mock-api/mock-api.interceptor.ts b/src/@angor/lib/mock-api/mock-api.interceptor.ts index ca5a2c03..371718e2 100644 --- a/src/@angor/lib/mock-api/mock-api.interceptor.ts +++ b/src/@angor/lib/mock-api/mock-api.interceptor.ts @@ -1,3 +1,5 @@ +import { ANGOR_MOCK_API_DEFAULT_DELAY } from '@angor/lib/mock-api/mock-api.constants'; +import { AngorMockApiService } from '@angor/lib/mock-api/mock-api.service'; import { HttpErrorResponse, HttpEvent, @@ -6,8 +8,6 @@ import { HttpResponse, } from '@angular/common/http'; import { inject } from '@angular/core'; -import { ANGOR_MOCK_API_DEFAULT_DELAY } from '@angor/lib/mock-api/mock-api.constants'; -import { AngorMockApiService } from '@angor/lib/mock-api/mock-api.service'; import { Observable, delay, of, switchMap, throwError } from 'rxjs'; import { AngorMockApiMethods } from './mock-api.types'; @@ -43,11 +43,14 @@ export const mockApiInterceptor = ( switchMap((response) => { // If no response is returned, generate a 404 error if (!response) { - return throwError(() => new HttpErrorResponse({ - error: 'NOT FOUND', - status: 404, - statusText: 'NOT FOUND', - })); + return throwError( + () => + new HttpErrorResponse({ + error: 'NOT FOUND', + status: 404, + statusText: 'NOT FOUND', + }) + ); } // Parse the response data (status and body) @@ -58,19 +61,24 @@ export const mockApiInterceptor = ( // If status code is between 200 and 300, return a successful response if (data.status >= 200 && data.status < 300) { - return of(new HttpResponse({ - body: data.body, - status: data.status, - statusText: 'OK', - })); + return of( + new HttpResponse({ + body: data.body, + status: data.status, + statusText: 'OK', + }) + ); } // For other status codes, throw an error response - return throwError(() => new HttpErrorResponse({ - error: data.body?.error, - status: data.status, - statusText: 'ERROR', - })); + return throwError( + () => + new HttpErrorResponse({ + error: data.body?.error, + status: data.status, + statusText: 'ERROR', + }) + ); }) ); }; diff --git a/src/@angor/lib/mock-api/mock-api.request-handler.ts b/src/@angor/lib/mock-api/mock-api.request-handler.ts index fed2e4c5..1534082c 100644 --- a/src/@angor/lib/mock-api/mock-api.request-handler.ts +++ b/src/@angor/lib/mock-api/mock-api.request-handler.ts @@ -1,5 +1,5 @@ -import { HttpRequest } from '@angular/common/http'; import { AngorMockApiReplyCallback } from '@angor/lib/mock-api/mock-api.types'; +import { HttpRequest } from '@angular/common/http'; import { Observable, of, take, throwError } from 'rxjs'; export class AngorMockApiHandler { @@ -17,7 +17,10 @@ export class AngorMockApiHandler { * @param url - The URL for the mock API handler * @param delay - Optional delay for the response */ - constructor(public url: string, public delay?: number) {} + constructor( + public url: string, + public delay?: number + ) {} /** * Getter for the response observable. @@ -27,12 +30,16 @@ export class AngorMockApiHandler { get response(): Observable { // Check if the execution limit has been reached if (this._replyCount > 0 && this._replyCount <= this._replied) { - return throwError(() => new Error('Execution limit has been reached!')); + return throwError( + () => new Error('Execution limit has been reached!') + ); } // Ensure the response callback exists if (!this._reply) { - return throwError(() => new Error('Response callback function does not exist!')); + return throwError( + () => new Error('Response callback function does not exist!') + ); } // Ensure the request exists diff --git a/src/@angor/lib/mock-api/mock-api.service.ts b/src/@angor/lib/mock-api/mock-api.service.ts index a74c0813..d4e21893 100644 --- a/src/@angor/lib/mock-api/mock-api.service.ts +++ b/src/@angor/lib/mock-api/mock-api.service.ts @@ -1,11 +1,14 @@ -import { Injectable } from '@angular/core'; import { AngorMockApiHandler } from '@angor/lib/mock-api/mock-api.request-handler'; import { AngorMockApiMethods } from '@angor/lib/mock-api/mock-api.types'; -import { compact, fromPairs } from 'lodash-es'; +import { Injectable } from '@angular/core'; +import { fromPairs } from 'lodash-es'; @Injectable({ providedIn: 'root' }) export class AngorMockApiService { - private readonly _handlers: Record> = { + private readonly _handlers: Record< + AngorMockApiMethods, + Map + > = { get: new Map(), post: new Map(), patch: new Map(), @@ -26,24 +29,36 @@ export class AngorMockApiService { findHandler( method: AngorMockApiMethods, url: string - ): { handler: AngorMockApiHandler | undefined; urlParams: Record } { - const matchingHandler = { handler: undefined, urlParams: {} as Record }; + ): { + handler: AngorMockApiHandler | undefined; + urlParams: Record; + } { + const matchingHandler = { + handler: undefined, + urlParams: {} as Record, + }; const urlParts = url.split('/'); - const handlers = this._handlers[method.toLowerCase() as AngorMockApiMethods]; + const handlers = + this._handlers[method.toLowerCase() as AngorMockApiMethods]; for (const [handlerUrl, handler] of handlers) { const handlerUrlParts = handlerUrl.split('/'); if (urlParts.length === handlerUrlParts.length) { - const matches = handlerUrlParts.every((part, index) => - part.startsWith(':') || part === urlParts[index] + const matches = handlerUrlParts.every( + (part, index) => + part.startsWith(':') || part === urlParts[index] ); if (matches) { matchingHandler.handler = handler; matchingHandler.urlParams = fromPairs( handlerUrlParts - .map((part, index) => (part.startsWith(':') ? [part.substring(1), urlParts[index]] : undefined)) + .map((part, index) => + part.startsWith(':') + ? [part.substring(1), urlParts[index]] + : undefined + ) .filter(Boolean) ); break; @@ -150,7 +165,11 @@ export class AngorMockApiService { * @param delay - (Optional) Delay for the response in milliseconds * @returns An instance of AngorMockApiHandler */ - private _registerHandler(method: AngorMockApiMethods, url: string, delay?: number): AngorMockApiHandler { + private _registerHandler( + method: AngorMockApiMethods, + url: string, + delay?: number + ): AngorMockApiHandler { const handler = new AngorMockApiHandler(url, delay); this._handlers[method].set(url, handler); return handler; diff --git a/src/@angor/pipes/find-by-key/find-by-key.pipe.ts b/src/@angor/pipes/find-by-key/find-by-key.pipe.ts index 44ba3917..4885afe0 100644 --- a/src/@angor/pipes/find-by-key/find-by-key.pipe.ts +++ b/src/@angor/pipes/find-by-key/find-by-key.pipe.ts @@ -18,7 +18,11 @@ export class AngorFindByKeyPipe implements PipeTransform { * @param source The array of objects to search within. * @returns A single object if `value` is a string, or an array of objects if `value` is an array. */ - transform(value: string | string[], key: string, source: any[]): any | any[] { + transform( + value: string | string[], + key: string, + source: any[] + ): any | any[] { // If value is an array of strings, map each to its corresponding object in the source. if (Array.isArray(value)) { return value.map((item) => diff --git a/src/@angor/services/config/config.service.ts b/src/@angor/services/config/config.service.ts index 4199db82..b6991bf6 100644 --- a/src/@angor/services/config/config.service.ts +++ b/src/@angor/services/config/config.service.ts @@ -1,12 +1,14 @@ -import { inject, Injectable } from '@angular/core'; import { ANGOR_CONFIG } from '@angor/services/config/config.constants'; +import { inject, Injectable } from '@angular/core'; import { merge } from 'lodash-es'; import { BehaviorSubject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AngorConfigService { private readonly _defaultConfig = inject(ANGOR_CONFIG); - private readonly _configSubject = new BehaviorSubject(this._defaultConfig); + private readonly _configSubject = new BehaviorSubject( + this._defaultConfig + ); /** * Getter for config as an Observable. diff --git a/src/@angor/services/config/config.types.ts b/src/@angor/services/config/config.types.ts index 5eda7b03..eafc614a 100644 --- a/src/@angor/services/config/config.types.ts +++ b/src/@angor/services/config/config.types.ts @@ -9,9 +9,9 @@ export type Themes = Array<{ id: string; name: string }>; * This ensures consistency when defining or updating app settings. */ export interface AngorConfig { - layout: string; // Layout type (e.g., 'vertical', 'horizontal') - scheme: Scheme; // Color scheme: 'auto', 'dark', or 'light' - screens: Screens; // Screen breakpoints, e.g., { 'xs': '600px', ... } - theme: Theme; // Active theme identifier, e.g., 'theme-default' - themes: Themes; // List of available themes, each with an id and name + layout: string; // Layout type (e.g., 'vertical', 'horizontal') + scheme: Scheme; // Color scheme: 'auto', 'dark', or 'light' + screens: Screens; // Screen breakpoints, e.g., { 'xs': '600px', ... } + theme: Theme; // Active theme identifier, e.g., 'theme-default' + themes: Themes; // List of available themes, each with an id and name } diff --git a/src/@angor/services/confirmation/confirmation.service.ts b/src/@angor/services/confirmation/confirmation.service.ts index c48c2c26..72b1fc06 100644 --- a/src/@angor/services/confirmation/confirmation.service.ts +++ b/src/@angor/services/confirmation/confirmation.service.ts @@ -1,7 +1,7 @@ -import { inject, Injectable } from '@angular/core'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types'; import { AngorConfirmationDialogComponent } from '@angor/services/confirmation/dialog/dialog.component'; +import { inject, Injectable } from '@angular/core'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { merge } from 'lodash-es'; @Injectable({ providedIn: 'root' }) diff --git a/src/@angor/services/confirmation/dialog/dialog.component.ts b/src/@angor/services/confirmation/dialog/dialog.component.ts index 455a491b..51dc48cb 100644 --- a/src/@angor/services/confirmation/dialog/dialog.component.ts +++ b/src/@angor/services/confirmation/dialog/dialog.component.ts @@ -1,9 +1,9 @@ +import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types'; import { NgClass } from '@angular/common'; import { Component, ViewEncapsulation, inject } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; -import { AngorConfirmationConfig } from '@angor/services/confirmation/confirmation.types'; @Component({ selector: 'angor-confirmation-dialog', diff --git a/src/@angor/services/loading/loading.interceptor.ts b/src/@angor/services/loading/loading.interceptor.ts index c7154114..4407b269 100644 --- a/src/@angor/services/loading/loading.interceptor.ts +++ b/src/@angor/services/loading/loading.interceptor.ts @@ -1,6 +1,6 @@ +import { AngorLoadingService } from '@angor/services/loading/loading.service'; import { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; import { inject } from '@angular/core'; -import { AngorLoadingService } from '@angor/services/loading/loading.service'; import { Observable, finalize, take } from 'rxjs'; export const angorLoadingInterceptor = ( diff --git a/src/@angor/services/media-watcher/media-watcher.service.ts b/src/@angor/services/media-watcher/media-watcher.service.ts index 8e14c952..ebd41819 100644 --- a/src/@angor/services/media-watcher/media-watcher.service.ts +++ b/src/@angor/services/media-watcher/media-watcher.service.ts @@ -1,6 +1,6 @@ +import { AngorConfigService } from '@angor/services/config'; import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { Injectable, inject } from '@angular/core'; -import { AngorConfigService } from '@angor/services/config'; import { fromPairs } from 'lodash-es'; import { Observable, ReplaySubject, map, switchMap } from 'rxjs'; diff --git a/src/@angor/styles/components/example-viewer.scss b/src/@angor/styles/components/example-viewer.scss index 157feb31..fa1c4ffc 100644 --- a/src/@angor/styles/components/example-viewer.scss +++ b/src/@angor/styles/components/example-viewer.scss @@ -1,6 +1,3 @@ -/* ----------------------------------------------------------------------------------------------------- */ -/* @ Example viewer -/* ----------------------------------------------------------------------------------------------------- */ .example-viewer { display: flex; flex-direction: column; diff --git a/src/@angor/styles/themes.scss b/src/@angor/styles/themes.scss index 24eaa12c..50972f13 100644 --- a/src/@angor/styles/themes.scss +++ b/src/@angor/styles/themes.scss @@ -80,7 +80,6 @@ $dark-base: ( ), ); - /* Include the core Angular Material styles */ @include mat.core(); diff --git a/src/@angor/tailwind/plugins/theming.js b/src/@angor/tailwind/plugins/theming.js index fd1d743e..0cac64f0 100644 --- a/src/@angor/tailwind/plugins/theming.js +++ b/src/@angor/tailwind/plugins/theming.js @@ -13,17 +13,6 @@ const jsonToSassMap = require( path.resolve(__dirname, '../utils/json-to-sass-map') ); -// ----------------------------------------------------------------------------------------------------- -// @ Utilities -// ----------------------------------------------------------------------------------------------------- - -/** - * Normalizes the provided theme by omitting empty values and values that - * start with "on" from each palette. Also sets the correct DEFAULT value - * of each palette. - * - * @param theme - */ const normalizeTheme = (theme) => { return _.fromPairs( _.map( @@ -43,17 +32,10 @@ const normalizeTheme = (theme) => { ); }; -// ----------------------------------------------------------------------------------------------------- -// @ ANGOR TailwindCSS Main Plugin -// ----------------------------------------------------------------------------------------------------- const theming = plugin.withOptions( (options) => ({ addComponents, e, theme }) => { - /** - * Create user themes object by going through the provided themes and - * merging them with the provided "default" so, we can have a complete - * set of color palettes for each user theme. - */ + const userThemes = _.fromPairs( _.map(options.themes, (theme, themeName) => [ themeName, @@ -61,10 +43,6 @@ const theming = plugin.withOptions( ]) ); - /** - * Normalize the themes and assign it to the themes object. This will - * be the final object that we create a SASS map from - */ let themes = _.fromPairs( _.map(userThemes, (theme, themeName) => [ themeName, @@ -72,10 +50,6 @@ const theming = plugin.withOptions( ]) ); - /** - * Go through the themes to generate the contrasts and filter the - * palettes to only have "primary", "accent" and "warn" objects. - */ themes = _.fromPairs( _.map(themes, (theme, themeName) => [ themeName, @@ -105,10 +79,6 @@ const theming = plugin.withOptions( ]) ); - /** - * Go through the themes and attach appropriate class selectors so, - * we can use them to encapsulate each theme. - */ themes = _.fromPairs( _.map(themes, (theme, themeName) => [ themeName, @@ -119,18 +89,15 @@ const theming = plugin.withOptions( ]) ); - /* Generate the SASS map using the themes object */ const sassMap = jsonToSassMap( JSON.stringify({ 'user-themes': themes }) ); - /* Get the file path */ const filename = path.resolve( __dirname, '../../styles/user-themes.scss' ); - /* Read the file and get its data */ let data; try { data = fs.readFileSync(filename, { encoding: 'utf8' }); @@ -138,7 +105,6 @@ const theming = plugin.withOptions( console.error(err); } - /* Write the file if the map has been changed */ if (data !== sassMap) { try { fs.writeFileSync(filename, sassMap, { encoding: 'utf8' }); @@ -147,11 +113,6 @@ const theming = plugin.withOptions( } } - /** - * Iterate through the user's themes and build Tailwind components containing - * CSS Custom Properties using the colors from them. This allows switching - * themes by simply replacing a class name as well as nesting them. - */ addComponents( _.fromPairs( _.map(options.themes, (theme, themeName) => [ @@ -214,9 +175,7 @@ const theming = plugin.withOptions( ) ); - /** - * Generate scheme based css custom properties and utility classes - */ + const schemeCustomProps = _.map( ['light', 'dark'], (colorScheme) => { @@ -234,31 +193,9 @@ const theming = plugin.withOptions( return { [isDark ? darkSchemeSelectors : lightSchemeSelectors]: { - /** - * If a custom property is not available, browsers will use - * the fallback value. In this case, we want to use '--is-dark' - * as the indicator of a dark theme so, we can use it like this: - * background-color: var(--is-dark, red); - * - * If we set '--is-dark' as "true" on dark themes, the above rule - * won't work because of the said "fallback value" logic. Therefore, - * we set the '--is-dark' to "false" on light themes and not set it - * at all on dark themes so that the fallback value can be used on - * dark themes. - * - * On light themes, since '--is-dark' exists, the above rule will be - * interpolated as: - * "background-color: false" - * - * On dark themes, since '--is-dark' doesn't exist, the fallback value - * will be used ('red' in this case) and the rule will be interpolated as: - * "background-color: red" - * - * It's easier to understand and remember like this. - */ + ...(!isDark ? { '--is-dark': 'false' } : {}), - /* Generate custom properties from customProps */ ..._.fromPairs( _.flatten( _.map(background, (value, key) => [ @@ -287,7 +224,6 @@ const theming = plugin.withOptions( ); const schemeUtilities = (() => { - /* Generate general styles & utilities */ return {}; })(); @@ -298,11 +234,6 @@ const theming = plugin.withOptions( return { theme: { extend: { - /** - * Add 'Primary', 'Accent' and 'Warn' palettes as colors so all color utilities - * are generated for them; "bg-primary", "text-on-primary", "bg-accent-600" etc. - * This will also allow using arbitrary values with them such as opacity and such. - */ colors: _.fromPairs( _.flatten( _.map( @@ -398,7 +329,6 @@ const theming = plugin.withOptions( }, }, }, - }, }; } diff --git a/src/@angor/tailwind/plugins/utilities.js b/src/@angor/tailwind/plugins/utilities.js index ccad64c9..30ce41e8 100644 --- a/src/@angor/tailwind/plugins/utilities.js +++ b/src/@angor/tailwind/plugins/utilities.js @@ -1,11 +1,6 @@ const plugin = require('tailwindcss/plugin'); module.exports = plugin(({ addComponents }) => { - /* - * Add base components. These are very important for everything to look - * correct. We are adding these to the 'components' layer because they must - * be defined before pretty much everything else. - */ addComponents({ '.mat-icon': { '--tw-text-opacity': '1', diff --git a/src/app/app.component.scss b/src/app/app.component.scss index a8b9eb09..9c62f14e 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,6 +1,6 @@ :host { display: flex; - flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto - width: 100%; // Full width of the container - height: 100%; // Full height of the container + flex: 1 1 auto; // Flex-grow, flex-shrink, and basis set to auto + width: 100%; // Full width of the container + height: 100%; // Full height of the container } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index c49ccef4..64a603bf 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,5 +1,11 @@ +import { provideAngor } from '@angor'; import { provideHttpClient } from '@angular/common/http'; -import { APP_INITIALIZER, ApplicationConfig, inject, isDevMode } from '@angular/core'; +import { + APP_INITIALIZER, + ApplicationConfig, + inject, + isDevMode, +} from '@angular/core'; import { LuxonDateAdapter } from '@angular/material-luxon-adapter'; import { DateAdapter, MAT_DATE_FORMATS } from '@angular/material/core'; import { provideAnimations } from '@angular/platform-browser/animations'; @@ -9,20 +15,18 @@ import { withInMemoryScrolling, withPreloading, } from '@angular/router'; -import { provideAngor } from '@angor'; +import { provideServiceWorker } from '@angular/service-worker'; import { TranslocoService, provideTransloco } from '@ngneat/transloco'; +import { WebLNProvider } from '@webbtc/webln-types'; import { appRoutes } from 'app/app.routes'; import { provideIcons } from 'app/core/icons/icons.provider'; import { firstValueFrom } from 'rxjs'; import { TranslocoHttpLoader } from './core/transloco/transloco.http-loader'; -import { provideServiceWorker } from '@angular/service-worker'; -import { HashService } from './services/hash.service'; import { navigationServices } from './layout/navigation/navigation.services'; -import { WebLNProvider } from '@webbtc/webln-types'; +import { HashService } from './services/hash.service'; import { NostrWindow } from './types/nostr'; export function initializeApp(hashService: HashService) { - console.log('initializeApp. Getting hashService.load.'); return (): Promise => hashService.load(); } export const appConfig: ApplicationConfig = { @@ -31,7 +35,7 @@ export const appConfig: ApplicationConfig = { provideHttpClient(), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), - registrationStrategy: 'registerWhenStable:30000' + registrationStrategy: 'registerWhenStable:30000', }), { provide: APP_INITIALIZER, @@ -54,12 +58,12 @@ export const appConfig: ApplicationConfig = { provide: MAT_DATE_FORMATS, useValue: { parse: { - dateInput: 'D', // Date format for parsing + dateInput: 'D', // Date format for parsing }, display: { - dateInput: 'DDD', // Date format for input display - monthYearLabel: 'LLL yyyy', // Format for month-year labels - dateA11yLabel: 'DD', // Accessible format for dates + dateInput: 'DDD', // Date format for input display + monthYearLabel: 'LLL yyyy', // Format for month-year labels + dateA11yLabel: 'DD', // Accessible format for dates monthYearA11yLabel: 'LLLL yyyy', // Accessible format for month-year }, }, @@ -72,7 +76,7 @@ export const appConfig: ApplicationConfig = { { id: 'en', label: 'English', - } + }, ], defaultLang: 'en', fallbackLang: 'en', @@ -121,13 +125,11 @@ export const appConfig: ApplicationConfig = { ], }, }), - ], }; declare global { interface Window { - webln?: WebLNProvider; - nostr?: NostrWindow; + webln?: WebLNProvider; + nostr?: NostrWindow; } - } - +} diff --git a/src/app/app.resolvers.ts b/src/app/app.resolvers.ts index 30ccba7d..0ae4057f 100644 --- a/src/app/app.resolvers.ts +++ b/src/app/app.resolvers.ts @@ -9,11 +9,11 @@ import { forkJoin } from 'rxjs'; */ export const initialDataResolver = () => { const navigationService = inject(NavigationService); - const quickChatService = inject(QuickChatService); + const quickChatService = inject(QuickChatService); // Combine API calls into a single observable return forkJoin([ - navigationService.get(), // Fetch navigation data + navigationService.get(), // Fetch navigation data // quickChatService.getChats(), // Fetch chat data ]); }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index e64ac3e0..d302ed56 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,31 +1,30 @@ import { Route } from '@angular/router'; import { initialDataResolver } from 'app/app.resolvers'; - import { LayoutComponent } from 'app/layout/layout.component'; +import { LayoutComponent } from 'app/layout/layout.component'; import { authGuard } from './core/auth/auth.guard'; /** * Application routes configuration */ export const appRoutes: Route[] = [ - // Redirect root path to '/explore' { path: '', pathMatch: 'full', - redirectTo: 'home' + redirectTo: 'home', }, { path: 'project/:pubkey', pathMatch: 'full', - redirectTo: 'explore' + redirectTo: 'explore', }, // Redirect login user to '/explore' { path: 'login-redirect', pathMatch: 'full', - redirectTo: 'explore' + redirectTo: 'explore', }, // Routes for guests @@ -36,13 +35,15 @@ export const appRoutes: Route[] = [ children: [ { path: 'login', - loadChildren: () => import('app/components/auth/login/login.routes') + loadChildren: () => + import('app/components/auth/login/login.routes'), }, { path: 'register', - loadChildren: () => import('app/components/auth/register/register.routes') - } - ] + loadChildren: () => + import('app/components/auth/register/register.routes'), + }, + ], }, // Routes for authenticated users @@ -55,13 +56,12 @@ export const appRoutes: Route[] = [ children: [ { path: 'logout', - loadChildren: () => import('app/components/auth/logout/logout.routes') - } - ] + loadChildren: () => + import('app/components/auth/logout/logout.routes'), + }, + ], }, - - // Authenticated routes for Angor { path: '', @@ -72,41 +72,49 @@ export const appRoutes: Route[] = [ children: [ { path: 'home', - loadChildren: () => import('app/components/home/home.routes') + loadChildren: () => import('app/components/home/home.routes'), }, { path: 'explore', - loadChildren: () => import('app/components/explore/explore.routes') + loadChildren: () => + import('app/components/explore/explore.routes'), }, { path: 'profile', - loadChildren: () => import('app/components/profile/profile.routes') + loadChildren: () => + import('app/components/profile/profile.routes'), }, { path: 'profile/:pubkey', - loadChildren: () => import('app/components/profile/profile.routes') + loadChildren: () => + import('app/components/profile/profile.routes'), }, { path: 'settings', - loadChildren: () => import('app/components/settings/settings.routes') + loadChildren: () => + import('app/components/settings/settings.routes'), }, { path: 'settings/:id', - loadChildren: () => import('app/components/settings/settings.routes') + loadChildren: () => + import('app/components/settings/settings.routes'), }, { path: 'chat', - loadChildren: () => import('app/components/chat/chat.routes') + loadChildren: () => import('app/components/chat/chat.routes'), }, { path: '404-not-found', pathMatch: 'full', - loadChildren: () => import('app/components/pages/error/error-404/error-404.routes') + loadChildren: () => + import( + 'app/components/pages/error/error-404/error-404.routes' + ), }, { path: '**', - redirectTo: '404-not-found' - } - ] - } + redirectTo: '404-not-found', + }, + ], + }, ]; diff --git a/src/app/components/auth/login/login.component.html b/src/app/components/auth/login/login.component.html index ab4633f2..25292599 100644 --- a/src/app/components/auth/login/login.component.html +++ b/src/app/components/auth/login/login.component.html @@ -1,18 +1,33 @@ -
+
+ class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none" + >
-
+
Login
Don't have an account?
- Register + Register
- + {{ secAlert.message }} @@ -26,8 +41,16 @@
-
@@ -40,7 +63,11 @@
-
+
@@ -49,8 +76,14 @@
Secret Key - - @if (SecretKeyLoginForm.get('secretKey').hasError('required')) { + + @if ( + SecretKeyLoginForm.get('secretKey').hasError('required') + ) { Secret key is required } @@ -58,47 +91,94 @@ Password - - - Password is required + + Password is required -
-
Or enter menemonic
- + {{ menemonicAlert.message }} -
+ Menemonic - - @if (MenemonicLoginForm.get('menemonic').hasError('required')) { + + @if ( + MenemonicLoginForm.get('menemonic').hasError('required') + ) { Menemonic is required } @@ -106,66 +186,156 @@ Passphrase (Optional) - - - Passphrase is required + + Passphrase is required Password - - - Password is required + + Password is required -
diff --git a/src/app/components/auth/logout/logout.component.ts b/src/app/components/auth/logout/logout.component.ts index 43d89deb..4a476fda 100644 --- a/src/app/components/auth/logout/logout.component.ts +++ b/src/app/components/auth/logout/logout.component.ts @@ -19,7 +19,10 @@ export class LogoutComponent implements OnInit, OnDestroy { }; private _unsubscribeAll: Subject = new Subject(); - constructor(private _router: Router, private _signerService: SignerService) {} + constructor( + private _router: Router, + private _signerService: SignerService + ) {} ngOnInit(): void { timer(1000, 1000) @@ -35,7 +38,6 @@ export class LogoutComponent implements OnInit, OnDestroy { .subscribe(); } - ngOnDestroy(): void { this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); @@ -44,6 +46,6 @@ export class LogoutComponent implements OnInit, OnDestroy { logout(): void { this._signerService.clearPassword(); this._signerService.logout(); - console.log("User logged out and keys removed from localStorage."); + console.log('User logged out and keys removed from localStorage.'); } } diff --git a/src/app/components/auth/register/register.component.html b/src/app/components/auth/register/register.component.html index dcb51feb..4da0a84e 100644 --- a/src/app/components/auth/register/register.component.html +++ b/src/app/components/auth/register/register.component.html @@ -1,107 +1,231 @@ -
+
+ class="w-full px-4 py-8 sm:bg-card sm:w-auto sm:rounded-2xl sm:p-12 sm:shadow md:flex md:h-full md:w-1/2 md:items-center md:justify-end md:rounded-none md:p-16 md:shadow-none" + >
-
+
Register
Already have an account?
- Login + Login
@if (showAlert) { - - {{ alert.message }} - + + {{ alert.message }} + } -
+ Full name - - Full name is required + + + Full name is required + Username - - Username is required + + + Username is required + About - + Avatar URL - + - + Password - - - Password is required + + Password is required +
- + I agree with - Terms + Terms and - Privacy Policy + Privacy Policy
-
-
diff --git a/src/app/components/auth/register/register.component.ts b/src/app/components/auth/register/register.component.ts index cba6cd18..a143e9cb 100644 --- a/src/app/components/auth/register/register.component.ts +++ b/src/app/components/auth/register/register.component.ts @@ -1,3 +1,6 @@ +import { angorAnimations } from '@angor/animations'; +import { AngorAlertComponent, AngorAlertType } from '@angor/components/alert'; +import { CommonModule } from '@angular/common'; import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core'; import { FormsModule, @@ -14,11 +17,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { Router, RouterLink } from '@angular/router'; -import { angorAnimations } from '@angor/animations'; -import { AngorAlertComponent, AngorAlertType } from '@angor/components/alert'; -import { SecurityService } from 'app/services/security.service'; import { SignerService } from 'app/services/signer.service'; -import { CommonModule } from '@angular/common'; @Component({ selector: 'auth-register', @@ -37,7 +36,7 @@ import { CommonModule } from '@angular/common'; MatIconModule, MatCheckboxModule, MatProgressSpinnerModule, - CommonModule + CommonModule, ], }) export class RegisterComponent implements OnInit { @@ -89,7 +88,10 @@ export class RegisterComponent implements OnInit { if (!keys) { // If key generation failed, enable the form and show an error this.registerForm.enable(); - this.alert = { type: 'error', message: 'Error generating keys. Please try again.' }; + this.alert = { + type: 'error', + message: 'Error generating keys. Please try again.', + }; this.showAlert = true; return; } @@ -112,11 +114,13 @@ export class RegisterComponent implements OnInit { console.log('User Metadata:', userMetadata); // Display success alert - this.alert = { type: 'success', message: 'Account created successfully!' }; + this.alert = { + type: 'success', + message: 'Account created successfully!', + }; this.showAlert = true; // Redirect to home this._router.navigateByUrl('/home'); } - } diff --git a/src/app/components/chat/chat.routes.ts b/src/app/components/chat/chat.routes.ts index e0c9af8a..6a75382a 100644 --- a/src/app/components/chat/chat.routes.ts +++ b/src/app/components/chat/chat.routes.ts @@ -25,7 +25,8 @@ const conversationResolver = ( const chatService = inject(ChatService); const router = inject(Router); - let chatId = route.paramMap.get('id') || localStorage.getItem('currentChatId'); + let chatId = + route.paramMap.get('id') || localStorage.getItem('currentChatId'); if (!chatId) { const parentUrl = state.url.split('/').slice(0, -1).join('/'); @@ -45,7 +46,6 @@ const conversationResolver = ( ); }; - export default [ { path: '', diff --git a/src/app/components/chat/chat.service.ts b/src/app/components/chat/chat.service.ts index 6609af03..e44d7914 100644 --- a/src/app/components/chat/chat.service.ts +++ b/src/app/components/chat/chat.service.ts @@ -1,14 +1,28 @@ import { Injectable, OnDestroy } from '@angular/core'; -import { BehaviorSubject, Observable, Subject, throwError, of, Subscriber, from } from 'rxjs'; -import { catchError, filter, map, switchMap, take, takeUntil, tap } from 'rxjs/operators'; import { Chat, Contact, Profile } from 'app/components/chat/chat.types'; import { IndexedDBService } from 'app/services/indexed-db.service'; import { MetadataService } from 'app/services/metadata.service'; -import { SignerService } from 'app/services/signer.service'; -import { Filter, NostrEvent } from 'nostr-tools'; import { RelayService } from 'app/services/relay.service'; +import { SignerService } from 'app/services/signer.service'; +import { Filter, getEventHash, NostrEvent } from 'nostr-tools'; import { EncryptedDirectMessage } from 'nostr-tools/kinds'; -import { getEventHash } from 'nostr-tools'; +import { + BehaviorSubject, + from, + Observable, + of, + Subject, + throwError, +} from 'rxjs'; +import { + catchError, + filter, + map, + switchMap, + take, + takeUntil, + tap, +} from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class ChatService implements OnDestroy { @@ -18,22 +32,26 @@ export class ChatService implements OnDestroy { private isDecrypting = false; private recipientPublicKey: string; private message: string; - private decryptedPrivateKey: string = ""; + private decryptedPrivateKey: string = ''; private _chat: BehaviorSubject = new BehaviorSubject(null); private _chats: BehaviorSubject = new BehaviorSubject(null); - private _contact: BehaviorSubject = new BehaviorSubject(null); - private _contacts: BehaviorSubject = new BehaviorSubject(null); - private _profile: BehaviorSubject = new BehaviorSubject(null); + private _contact: BehaviorSubject = new BehaviorSubject( + null + ); + private _contacts: BehaviorSubject = new BehaviorSubject( + null + ); + private _profile: BehaviorSubject = new BehaviorSubject( + null + ); private _unsubscribeAll: Subject = new Subject(); constructor( private _metadataService: MetadataService, private _signerService: SignerService, private _indexedDBService: IndexedDBService, - private _relayService: RelayService, - - - ) { } + private _relayService: RelayService + ) {} get profile$(): Observable { return this._profile.asObservable(); } @@ -54,46 +72,50 @@ export class ChatService implements OnDestroy { return this._contacts.asObservable(); } - checkCurrentChatOnPageRefresh(chatIdFromURL: string): void { if (chatIdFromURL) { const currentChat = this._chat.value; - this.getChatById(chatIdFromURL).subscribe(chat => { + this.getChatById(chatIdFromURL).subscribe((chat) => { if (chat) { this._chat.next(chat); this.loadChatHistory(chatIdFromURL); } }); - } } - async getContact(pubkey: string): Promise { try { if (!pubkey) { return; } - const metadata = await this._metadataService.fetchMetadataWithCache(pubkey); + const metadata = + await this._metadataService.fetchMetadataWithCache(pubkey); if (metadata) { const contact: Contact = { pubKey: pubkey, displayName: metadata.name ? metadata.name : 'Unknown', picture: metadata.picture, - about: metadata.about + about: metadata.about, }; this._contact.next(contact); - this._indexedDBService.getMetadataStream() + this._indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === pubkey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === pubkey + ) { const updatedContact: Contact = { pubKey: pubkey, - displayName: updatedMetadata.metadata.name ? updatedMetadata.metadata.name : 'Unknown', + displayName: updatedMetadata.metadata.name + ? updatedMetadata.metadata.name + : 'Unknown', picture: updatedMetadata.metadata.picture, - about: updatedMetadata.metadata.about + about: updatedMetadata.metadata.about, }; this._contact.next(updatedContact); } @@ -104,18 +126,23 @@ export class ChatService implements OnDestroy { } } - getContacts(): Observable { return new Observable((observer) => { - this._indexedDBService.getAllUsers() + this._indexedDBService + .getAllUsers() .then((cachedContacts: Contact[]) => { if (cachedContacts && cachedContacts.length > 0) { - const validatedContacts = cachedContacts.map(contact => { - if (!contact.pubKey) { - console.error('Contact is missing pubKey:', contact); + const validatedContacts = cachedContacts.map( + (contact) => { + if (!contact.pubKey) { + console.error( + 'Contact is missing pubKey:', + contact + ); + } + return contact; } - return contact; - }); + ); this._contacts.next(validatedContacts); observer.next(validatedContacts); @@ -125,33 +152,49 @@ export class ChatService implements OnDestroy { observer.complete(); }) .catch((error) => { - console.error('Error loading cached contacts from IndexedDB:', error); + console.error( + 'Error loading cached contacts from IndexedDB:', + error + ); observer.next([]); observer.complete(); }); - return { unsubscribe() { } }; + return { unsubscribe() {} }; }); } async updateChatListMetadata(): Promise { - const pubKeys = this.chatList.map(chat => chat.contact?.pubKey).filter(pubKey => pubKey); + const pubKeys = this.chatList + .map((chat) => chat.contact?.pubKey) + .filter((pubKey) => pubKey); if (pubKeys.length > 0) { - const metadataList = await this._metadataService.fetchMetadataForMultipleKeys(pubKeys); + const metadataList = + await this._metadataService.fetchMetadataForMultipleKeys( + pubKeys + ); - metadataList.forEach(metadata => { - const contact = this._contacts.value?.find(contact => contact.pubKey === metadata.pubkey); + metadataList.forEach((metadata) => { + const contact = this._contacts.value?.find( + (contact) => contact.pubKey === metadata.pubkey + ); if (contact) { contact.displayName = metadata.metadata.name || 'Unknown'; - contact.picture = metadata.metadata.picture || contact.picture; + contact.picture = + metadata.metadata.picture || contact.picture; contact.about = metadata.metadata.about || contact.about; } - const chat = this.chatList.find(chat => chat.contact?.pubKey === metadata.pubkey); + const chat = this.chatList.find( + (chat) => chat.contact?.pubKey === metadata.pubkey + ); if (chat) { - chat.contact.displayName = metadata.metadata.name || 'Unknown'; - chat.contact.picture = metadata.metadata.picture || chat.contact.picture; - chat.contact.about = metadata.metadata.about || chat.contact.about; + chat.contact.displayName = + metadata.metadata.name || 'Unknown'; + chat.contact.picture = + metadata.metadata.picture || chat.contact.picture; + chat.contact.about = + metadata.metadata.about || chat.contact.about; } }); @@ -161,40 +204,60 @@ export class ChatService implements OnDestroy { } private subscribeToRealTimeMetadataUpdates(pubKey: string): void { - this._metadataService.getMetadataStream() - .pipe(filter(updatedMetadata => updatedMetadata && updatedMetadata.pubkey === pubKey)) - .subscribe(updatedMetadata => { - const chat = this.chatList.find(chat => chat.contact?.pubKey === pubKey); + this._metadataService + .getMetadataStream() + .pipe( + filter( + (updatedMetadata) => + updatedMetadata && updatedMetadata.pubkey === pubKey + ) + ) + .subscribe((updatedMetadata) => { + const chat = this.chatList.find( + (chat) => chat.contact?.pubKey === pubKey + ); if (chat) { - chat.contact.displayName = updatedMetadata.metadata.name || 'Unknown'; - chat.contact.picture = updatedMetadata.metadata.picture || chat.contact.picture; - chat.contact.about = updatedMetadata.metadata.about || chat.contact.about; + chat.contact.displayName = + updatedMetadata.metadata.name || 'Unknown'; + chat.contact.picture = + updatedMetadata.metadata.picture || + chat.contact.picture; + chat.contact.about = + updatedMetadata.metadata.about || chat.contact.about; this._chats.next(this.chatList); } - const contact = this._contacts.value?.find(contact => contact.pubKey === pubKey); + const contact = this._contacts.value?.find( + (contact) => contact.pubKey === pubKey + ); if (contact) { - contact.displayName = updatedMetadata.metadata.name || 'Unknown'; - contact.picture = updatedMetadata.metadata.picture || contact.picture; - contact.about = updatedMetadata.metadata.about || contact.about; + contact.displayName = + updatedMetadata.metadata.name || 'Unknown'; + contact.picture = + updatedMetadata.metadata.picture || contact.picture; + contact.about = + updatedMetadata.metadata.about || contact.about; this._contacts.next(this._contacts.value || []); } }); } - async getProfile(): Promise { try { const publicKey = this._signerService.getPublicKey(); - const metadata = await this._metadataService.fetchMetadataWithCache(publicKey); + const metadata = + await this._metadataService.fetchMetadataWithCache(publicKey); if (metadata) { this._profile.next(metadata); - - this._indexedDBService.getMetadataStream() + this._indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === publicKey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === publicKey + ) { this._profile.next(updatedMetadata.metadata); } }); @@ -206,14 +269,14 @@ export class ChatService implements OnDestroy { async getChats(): Promise> { return this.getChatListStream().pipe( - tap(chats => { + tap((chats) => { if (chats && chats.length === 0) { return; } const pubKeys = chats - .filter(chat => chat.contact?.pubKey) - .map(chat => chat.contact!.pubKey); + .filter((chat) => chat.contact?.pubKey) + .map((chat) => chat.contact!.pubKey); // Subscribe to all metadata updates in parallel this.subscribeToRealTimeMetadataUpdatesBatch(pubKeys); @@ -226,12 +289,19 @@ export class ChatService implements OnDestroy { const useExtension = await this._signerService.isUsingExtension(); const useSecretKey = await this._signerService.isUsingSecretKey(); - this.decryptedPrivateKey = useSecretKey ? await this._signerService.getDecryptedSecretKey() : ''; + this.decryptedPrivateKey = useSecretKey + ? await this._signerService.getDecryptedSecretKey() + : ''; // Perform metadata and chat loading in parallel for speed await Promise.all([ this.updateChatListMetadata(), - this.subscribeToChatList(pubkey, useExtension, useSecretKey, this.decryptedPrivateKey) + this.subscribeToChatList( + pubkey, + useExtension, + useSecretKey, + this.decryptedPrivateKey + ), ]); return this.getChatListStream(); @@ -239,46 +309,80 @@ export class ChatService implements OnDestroy { private subscribeToRealTimeMetadataUpdatesBatch(pubKeys: string[]): void { // Batch subscribe to all pubKeys metadata updates for efficiency - pubKeys.forEach(pubKey => { + pubKeys.forEach((pubKey) => { this.subscribeToRealTimeMetadataUpdates(pubKey); }); } - subscribeToChatList(pubkey: string, useExtension: boolean, useSecretKey: boolean, decryptedSenderPrivateKey: string): Observable { + subscribeToChatList( + pubkey: string, + useExtension: boolean, + useSecretKey: boolean, + decryptedSenderPrivateKey: string + ): Observable { this._relayService.ensureConnectedRelays().then(async () => { const filters: Filter[] = [ - { kinds: [EncryptedDirectMessage], authors: [pubkey] , limit:1500}, - { kinds: [EncryptedDirectMessage], '#p': [pubkey] , limit:1500} - ]; - - this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), filters, { - onevent: async (event: NostrEvent) => { - const otherPartyPubKey = event.pubkey === pubkey - ? event.tags.find(tag => tag[0] === 'p')?.[1] || '' - : event.pubkey; - - if (!otherPartyPubKey) return; - - const lastTimestamp = this.latestMessageTimestamps[otherPartyPubKey] || 0; - if (event.created_at > lastTimestamp) { - this.messageQueue.push(event); - this.processNextMessage(pubkey, useExtension, useSecretKey, decryptedSenderPrivateKey); - } + { + kinds: [EncryptedDirectMessage], + authors: [pubkey], + limit: 1500, }, - oneose: () => { + { + kinds: [EncryptedDirectMessage], + '#p': [pubkey], + limit: 1500, + }, + ]; - const currentChats = this.chatList || []; - if (currentChats.length > 0) { - this._chats.next(this.chatList); + this._relayService + .getPool() + .subscribeMany( + this._relayService.getConnectedRelays(), + filters, + { + onevent: async (event: NostrEvent) => { + const otherPartyPubKey = + event.pubkey === pubkey + ? event.tags.find( + (tag) => tag[0] === 'p' + )?.[1] || '' + : event.pubkey; + + if (!otherPartyPubKey) return; + + const lastTimestamp = + this.latestMessageTimestamps[ + otherPartyPubKey + ] || 0; + if (event.created_at > lastTimestamp) { + this.messageQueue.push(event); + this.processNextMessage( + pubkey, + useExtension, + useSecretKey, + decryptedSenderPrivateKey + ); + } + }, + oneose: () => { + const currentChats = this.chatList || []; + if (currentChats.length > 0) { + this._chats.next(this.chatList); + } + }, } - } - }); + ); }); return this.getChatListStream(); } - private async processNextMessage(pubkey: string, useExtension: boolean, useSecretKey: boolean, decryptedSenderPrivateKey: string): Promise { + private async processNextMessage( + pubkey: string, + useExtension: boolean, + useSecretKey: boolean, + decryptedSenderPrivateKey: string + ): Promise { if (this.isDecrypting || this.messageQueue.length === 0) return; this.isDecrypting = true; @@ -290,7 +394,7 @@ export class ChatService implements OnDestroy { const isSentByUser = event.pubkey === pubkey; const otherPartyPubKey = isSentByUser - ? event.tags.find(tag => tag[0] === 'p')?.[1] || '' + ? event.tags.find((tag) => tag[0] === 'p')?.[1] || '' : event.pubkey; if (!otherPartyPubKey) continue; @@ -304,7 +408,12 @@ export class ChatService implements OnDestroy { ); if (decryptedMessage) { - this.addOrUpdateChatList(otherPartyPubKey, decryptedMessage, event.created_at, isSentByUser); + this.addOrUpdateChatList( + otherPartyPubKey, + decryptedMessage, + event.created_at, + isSentByUser + ); } } } catch (error) { @@ -314,8 +423,15 @@ export class ChatService implements OnDestroy { } } - private addOrUpdateChatList(pubKey: string, message: string, createdAt: number, isMine: boolean): void { - const existingChat = this.chatList.find(chat => chat.contact?.pubKey === pubKey); + private addOrUpdateChatList( + pubKey: string, + message: string, + createdAt: number, + isMine: boolean + ): void { + const existingChat = this.chatList.find( + (chat) => chat.contact?.pubKey === pubKey + ); const newMessage = { id: `${pubKey}-${createdAt}`, @@ -327,11 +443,19 @@ export class ChatService implements OnDestroy { }; if (existingChat) { - const messageExists = existingChat.messages?.some(m => m.id === newMessage.id); + const messageExists = existingChat.messages?.some( + (m) => m.id === newMessage.id + ); if (!messageExists) { - existingChat.messages = [...(existingChat.messages || []), newMessage] - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + existingChat.messages = [ + ...(existingChat.messages || []), + newMessage, + ].sort( + (a, b) => + new Date(a.createdAt).getTime() - + new Date(b.createdAt).getTime() + ); if (Number(existingChat.lastMessageAt) < createdAt) { existingChat.lastMessage = message; @@ -339,25 +463,34 @@ export class ChatService implements OnDestroy { } } } else { - const contactInfo = this._contacts.value?.find(contact => contact.pubKey === pubKey) || { pubKey }; + const contactInfo = this._contacts.value?.find( + (contact) => contact.pubKey === pubKey + ) || { pubKey }; const newChat: Chat = { id: pubKey, contact: { pubKey: contactInfo.pubKey, - name: contactInfo.name || "Unknown", - picture: contactInfo.picture || "/images/avatars/avatar-placeholder.png", - about: contactInfo.about || "", - displayName: contactInfo.displayName || contactInfo.name || "Unknown" + name: contactInfo.name || 'Unknown', + picture: + contactInfo.picture || + '/images/avatars/avatar-placeholder.png', + about: contactInfo.about || '', + displayName: + contactInfo.displayName || + contactInfo.name || + 'Unknown', }, lastMessage: message, lastMessageAt: createdAt.toString(), - messages: [newMessage] + messages: [newMessage], }; this.chatList.push(newChat); } - this.chatList.sort((a, b) => Number(b.lastMessageAt!) - Number(a.lastMessageAt!)); + this.chatList.sort( + (a, b) => Number(b.lastMessageAt!) - Number(a.lastMessageAt!) + ); this._chats.next(this.chatList); } @@ -373,9 +506,16 @@ export class ChatService implements OnDestroy { recipientPublicKey: string ): Promise { if (useExtension && !useSecretKey) { - return await this._signerService.decryptMessageWithExtension(recipientPublicKey, event.content); + return await this._signerService.decryptMessageWithExtension( + recipientPublicKey, + event.content + ); } else if (useSecretKey && !useExtension) { - return await this._signerService.decryptMessage(decryptedSenderPrivateKey, recipientPublicKey, event.content); + return await this._signerService.decryptMessage( + decryptedSenderPrivateKey, + recipientPublicKey, + event.content + ); } } @@ -383,83 +523,143 @@ export class ChatService implements OnDestroy { const myPubKey = this._signerService.getPublicKey(); const historyFilter: Filter[] = [ - { kinds: [EncryptedDirectMessage], authors: [myPubKey], '#p': [pubKey], limit: 10 }, - { kinds: [EncryptedDirectMessage], authors: [pubKey], '#p': [myPubKey], limit: 10 } + { + kinds: [EncryptedDirectMessage], + authors: [myPubKey], + '#p': [pubKey], + limit: 10, + }, + { + kinds: [EncryptedDirectMessage], + authors: [pubKey], + '#p': [myPubKey], + limit: 10, + }, ]; - this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), historyFilter, { - onevent: async (event: NostrEvent) => { - const isSentByMe = event.pubkey === myPubKey; - const senderOrRecipientPubKey = isSentByMe ? pubKey : event.pubkey; - const useExtension = await this._signerService.isUsingExtension(); - const useSecretKey = await this._signerService.isUsingSecretKey(); - const decryptedMessage = await this.decryptReceivedMessage( - event, - useExtension, - useSecretKey, - this.decryptedPrivateKey, - senderOrRecipientPubKey - ); - - if (decryptedMessage) { - const messageTimestamp = Math.floor(event.created_at); - - this.addOrUpdateChatList(pubKey, decryptedMessage, messageTimestamp, isSentByMe); - this._chat.next(this.chatList.find(chat => chat.id === pubKey)); + this._relayService + .getPool() + .subscribeMany( + this._relayService.getConnectedRelays(), + historyFilter, + { + onevent: async (event: NostrEvent) => { + const isSentByMe = event.pubkey === myPubKey; + const senderOrRecipientPubKey = isSentByMe + ? pubKey + : event.pubkey; + const useExtension = + await this._signerService.isUsingExtension(); + const useSecretKey = + await this._signerService.isUsingSecretKey(); + const decryptedMessage = + await this.decryptReceivedMessage( + event, + useExtension, + useSecretKey, + this.decryptedPrivateKey, + senderOrRecipientPubKey + ); + + if (decryptedMessage) { + const messageTimestamp = Math.floor( + event.created_at + ); + + this.addOrUpdateChatList( + pubKey, + decryptedMessage, + messageTimestamp, + isSentByMe + ); + this._chat.next( + this.chatList.find((chat) => chat.id === pubKey) + ); + } + }, + oneose: () => {}, } - }, - oneose: () => { - } - }); + ); } private async fetchChatHistory(pubKey: string): Promise { const myPubKey = this._signerService.getPublicKey(); const historyFilter: Filter[] = [ - { kinds: [EncryptedDirectMessage], authors: [myPubKey], '#p': [pubKey], limit: 10 }, - { kinds: [EncryptedDirectMessage], authors: [pubKey], '#p': [myPubKey], limit: 10 } + { + kinds: [EncryptedDirectMessage], + authors: [myPubKey], + '#p': [pubKey], + limit: 10, + }, + { + kinds: [EncryptedDirectMessage], + authors: [pubKey], + '#p': [myPubKey], + limit: 10, + }, ]; const messages: any[] = []; - this._relayService.getPool().subscribeMany(this._relayService.getConnectedRelays(), historyFilter, { - onevent: async (event: NostrEvent) => { - const isSentByMe = event.pubkey === myPubKey; - const senderOrRecipientPubKey = isSentByMe ? pubKey : event.pubkey; - const useExtension = await this._signerService.isUsingExtension(); - const useSecretKey = await this._signerService.isUsingSecretKey(); - const decryptedMessage = await this.decryptReceivedMessage( - event, - useExtension, - useSecretKey, - this.decryptedPrivateKey, - senderOrRecipientPubKey - ); - - if (decryptedMessage) { - const messageTimestamp = Math.floor(event.created_at); - - const message = { - id: event.id, - chatId: pubKey, - contactId: senderOrRecipientPubKey, - isMine: isSentByMe, - value: decryptedMessage, - createdAt: new Date(messageTimestamp * 1000).toISOString(), - }; - - messages.push(message); + this._relayService + .getPool() + .subscribeMany( + this._relayService.getConnectedRelays(), + historyFilter, + { + onevent: async (event: NostrEvent) => { + const isSentByMe = event.pubkey === myPubKey; + const senderOrRecipientPubKey = isSentByMe + ? pubKey + : event.pubkey; + const useExtension = + await this._signerService.isUsingExtension(); + const useSecretKey = + await this._signerService.isUsingSecretKey(); + const decryptedMessage = + await this.decryptReceivedMessage( + event, + useExtension, + useSecretKey, + this.decryptedPrivateKey, + senderOrRecipientPubKey + ); + + if (decryptedMessage) { + const messageTimestamp = Math.floor( + event.created_at + ); + + const message = { + id: event.id, + chatId: pubKey, + contactId: senderOrRecipientPubKey, + isMine: isSentByMe, + value: decryptedMessage, + createdAt: new Date( + messageTimestamp * 1000 + ).toISOString(), + }; - this.addOrUpdateChatList(pubKey, decryptedMessage, messageTimestamp, isSentByMe); - this._chat.next(this.chatList.find(chat => chat.id === pubKey)); + messages.push(message); + + this.addOrUpdateChatList( + pubKey, + decryptedMessage, + messageTimestamp, + isSentByMe + ); + this._chat.next( + this.chatList.find((chat) => chat.id === pubKey) + ); + } + }, + oneose: () => {}, } - }, - oneose: () => { - } - }); + ); - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 1000)); return messages; } @@ -484,10 +684,14 @@ export class ChatService implements OnDestroy { event.id = getEventHash(event); - return from(this._relayService.publishEventToRelays(event)).pipe( + return from( + this._relayService.publishEventToRelays(event) + ).pipe( map(() => { if (chats) { - const index = chats.findIndex((item) => item.id === id); + const index = chats.findIndex( + (item) => item.id === id + ); if (index !== -1) { chats[index] = chat; this._chats.next(chats); @@ -496,7 +700,10 @@ export class ChatService implements OnDestroy { return chat; }), catchError((error) => { - console.error('Failed to update chat via Nostr:', error); + console.error( + 'Failed to update chat via Nostr:', + error + ); return throwError(error); }) ); @@ -516,7 +723,7 @@ export class ChatService implements OnDestroy { return this.createNewChat(id, contact); } - const cachedChat = chats.find(chat => chat.id === id); + const cachedChat = chats.find((chat) => chat.id === id); if (cachedChat) { this._chat.next(cachedChat); this.loadChatHistory(this.recipientPublicKey); @@ -534,8 +741,6 @@ export class ChatService implements OnDestroy { ); } - - createNewChat(id: string, contact: Contact = null): Observable { // const existingChat = this._chats.value?.find(chat => chat.id === id); @@ -545,11 +750,21 @@ export class ChatService implements OnDestroy { const newChat: Chat = { id: id || '', contact: contact - ? { pubKey: contact.pubKey || id, name: contact.name || 'Unknown', picture: contact.picture || '/images/avatars/avatar-placeholder.png' } - : { pubKey: id, name: 'Unknown', picture: '/images/avatars/avatar-placeholder.png' }, + ? { + pubKey: contact.pubKey || id, + name: contact.name || 'Unknown', + picture: + contact.picture || + '/images/avatars/avatar-placeholder.png', + } + : { + pubKey: id, + name: 'Unknown', + picture: '/images/avatars/avatar-placeholder.png', + }, lastMessage: 'new chat...', lastMessageAt: Math.floor(Date.now() / 1000).toString() || '0', - messages: [] + messages: [], }; // const updatedChats = this._chats.value ? [...this._chats.value, newChat] : [newChat]; @@ -559,10 +774,13 @@ export class ChatService implements OnDestroy { map((metadata: any) => { newChat.contact = { pubKey: id, - name: metadata?.name || "Unknown", - picture: metadata?.picture || "/images/avatars/avatar-placeholder.png", - about: metadata?.about || "", - displayName: metadata?.displayName || metadata?.name || "Unknown" + name: metadata?.name || 'Unknown', + picture: + metadata?.picture || + '/images/avatars/avatar-placeholder.png', + about: metadata?.about || '', + displayName: + metadata?.displayName || metadata?.name || 'Unknown', }; return newChat; @@ -576,8 +794,10 @@ export class ChatService implements OnDestroy { chatId: id, contactId: id, isMine: true, - value: "new chat...", - createdAt: Math.floor(Date.now() / 1000).toString(), + value: 'new chat...', + createdAt: Math.floor( + Date.now() / 1000 + ).toString(), }; messages.push(testMessage); @@ -591,7 +811,9 @@ export class ChatService implements OnDestroy { newChat.lastMessageAt = lastMessage.createdAt; } - const updatedChatsWithMessages = this._chats.value ? [...this._chats.value, newChat] : [newChat]; + const updatedChatsWithMessages = this._chats.value + ? [...this._chats.value, newChat] + : [newChat]; this._chats.next(updatedChatsWithMessages); this._chat.next(newChat); @@ -602,14 +824,10 @@ export class ChatService implements OnDestroy { ); } - - - resetChat(): void { this._chat.next(null); } - public async sendPrivateMessage(message: string): Promise { try { this.message = message; @@ -620,20 +838,31 @@ export class ChatService implements OnDestroy { await this.handleMessageSendingWithExtension(); } else if (!useExtension && useSecretKey) { if (!this.isValidMessageSetup()) { - console.error('Message, sender private key, or recipient public key is not properly set.'); + console.error( + 'Message, sender private key, or recipient public key is not properly set.' + ); return; } - const encryptedMessage = await this._signerService.encryptMessage( - this.decryptedPrivateKey, - this.recipientPublicKey, - this.message + const encryptedMessage = + await this._signerService.encryptMessage( + this.decryptedPrivateKey, + this.recipientPublicKey, + this.message + ); + + const messageEvent = this._signerService.getUnsignedEvent( + 4, + [['p', this.recipientPublicKey]], + encryptedMessage ); - const messageEvent = this._signerService.getUnsignedEvent(4, [['p', this.recipientPublicKey]], encryptedMessage); - - const signedEvent = this._signerService.getSignedEvent(messageEvent, this.decryptedPrivateKey); + const signedEvent = this._signerService.getSignedEvent( + messageEvent, + this.decryptedPrivateKey + ); - const published = await this._relayService.publishEventToRelays(signedEvent); + const published = + await this._relayService.publishEventToRelays(signedEvent); if (published) { this.message = ''; @@ -641,7 +870,6 @@ export class ChatService implements OnDestroy { console.error('Failed to send the message.'); } } - } catch (error) { console.error('Error sending private message:', error); } @@ -649,20 +877,23 @@ export class ChatService implements OnDestroy { private async handleMessageSendingWithExtension(): Promise { try { - const encryptedMessage = await this._signerService.encryptMessageWithExtension( - this.message, - this.recipientPublicKey - ); + const encryptedMessage = + await this._signerService.encryptMessageWithExtension( + this.message, + this.recipientPublicKey + ); - const signedEvent = await this._signerService.signEventWithExtension({ - kind: 4, - pubkey: this._signerService.getPublicKey(), - tags: [['p', this.recipientPublicKey]], - content: encryptedMessage, - created_at: Math.floor(Date.now() / 1000), - }); + const signedEvent = + await this._signerService.signEventWithExtension({ + kind: 4, + pubkey: this._signerService.getPublicKey(), + tags: [['p', this.recipientPublicKey]], + content: encryptedMessage, + created_at: Math.floor(Date.now() / 1000), + }); - const published = await this._relayService.publishEventToRelays(signedEvent); + const published = + await this._relayService.publishEventToRelays(signedEvent); if (published) { this.message = ''; @@ -682,6 +913,4 @@ export class ChatService implements OnDestroy { this._unsubscribeAll.next(); this._unsubscribeAll.complete(); } - - } diff --git a/src/app/components/chat/chats/chats.component.html b/src/app/components/chat/chats/chats.component.html index f28af1e5..7a7e3bc2 100644 --- a/src/app/components/chat/chats/chats.component.html +++ b/src/app/components/chat/chats/chats.component.html @@ -1,16 +1,20 @@
- + @if (drawerComponent === 'new-chat') { - + } @if (drawerComponent === 'profile') { - + } @@ -18,180 +22,276 @@ @if (chats && chats.length > 0) { -
- -
-
-
-
- @if (profile?.picture) { - Profile picture - } - @if (!profile?.picture) { -
- {{ profile?.name?.charAt(0) }} +
+ +
+
+
+
+ @if (profile?.picture) { + Profile picture + } + @if (!profile?.picture) { +
+ {{ profile?.name?.charAt(0) }} +
+ } +
+
+ {{ profile?.name }}
- } -
-
- {{ profile?.name }}
-
- - + - + - + - + - - + + - - -
- -
- - + Settings + + + +
+ +
+ + - - + " + > + + +
-
- - - - } - } @else { -
- + -
- No chats -
+ " + >
+
+ No chats +
+
+ }
- }
-
} @else { -
- + -
- No chats + " + > +
+ No chats +
-
} @if (chats && chats.length > 0) { -
- -
+ }" + > + +
} diff --git a/src/app/components/chat/chats/chats.component.ts b/src/app/components/chat/chats/chats.component.ts index 9834b15d..3e174fe0 100644 --- a/src/app/components/chat/chats/chats.component.ts +++ b/src/app/components/chat/chats/chats.component.ts @@ -14,13 +14,13 @@ import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; import { MatSidenavModule } from '@angular/material/sidenav'; import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router'; -import { catchError, of, Subject, takeUntil } from 'rxjs'; +import { AgoPipe } from 'app/shared/pipes/ago.pipe'; +import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe'; +import { Subject, takeUntil } from 'rxjs'; import { ChatService } from '../chat.service'; import { Chat, Profile } from '../chat.types'; import { NewChatComponent } from '../new-chat/new-chat.component'; import { ProfileComponent } from '../profile/profile.component'; -import { AgoPipe } from 'app/shared/pipes/ago.pipe'; -import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe'; @Component({ selector: 'chat-chats', @@ -42,7 +42,7 @@ import { CheckmessagePipe } from 'app/shared/pipes/checkmessage.pipe'; RouterOutlet, AgoPipe, CommonModule, - CheckmessagePipe + CheckmessagePipe, ], }) export class ChatsComponent implements OnInit, OnDestroy { @@ -67,9 +67,8 @@ export class ChatsComponent implements OnInit, OnDestroy { constructor( private _chatService: ChatService, private _changeDetectorRef: ChangeDetectorRef, - private route: ActivatedRoute, - - ) { } + private route: ActivatedRoute + ) {} /** * Angular lifecycle hook (ngOnInit) for component initialization. @@ -100,7 +99,7 @@ export class ChatsComponent implements OnInit, OnDestroy { this._markForCheck(); }); - this._chatService.InitSubscribeToChatList(); + this._chatService.InitSubscribeToChatList(); const savedChatId = localStorage.getItem('currentChatId'); @@ -128,7 +127,7 @@ export class ChatsComponent implements OnInit, OnDestroy { this.filteredChats = this.chats; } else { const lowerCaseQuery = query.toLowerCase(); - this.filteredChats = this.chats.filter(chat => + this.filteredChats = this.chats.filter((chat) => chat.contact?.name.toLowerCase().includes(lowerCaseQuery) ); } @@ -169,6 +168,4 @@ export class ChatsComponent implements OnInit, OnDestroy { private _markForCheck(): void { this._changeDetectorRef.markForCheck(); } - - } diff --git a/src/app/components/chat/contact-info/contact-info.component.html b/src/app/components/chat/contact-info/contact-info.component.html index 38696511..6cdcb393 100644 --- a/src/app/components/chat/contact-info/contact-info.component.html +++ b/src/app/components/chat/contact-info/contact-info.component.html @@ -29,12 +29,14 @@
}
-
{{ chat.contact?.name }}
-
+ +
{{ chat.contact?.about }}
- -
diff --git a/src/app/components/chat/contact-info/contact-info.component.ts b/src/app/components/chat/contact-info/contact-info.component.ts index bdfe17f3..7f367759 100644 --- a/src/app/components/chat/contact-info/contact-info.component.ts +++ b/src/app/components/chat/contact-info/contact-info.component.ts @@ -7,15 +7,16 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatDrawer } from '@angular/material/sidenav'; +import { RouterModule } from '@angular/router'; import { Chat } from '../chat.types'; - + @Component({ selector: 'chat-contact-info', templateUrl: './contact-info.component.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, - imports: [MatButtonModule, MatIconModule], + imports: [MatButtonModule, MatIconModule, RouterModule], }) export class ContactInfoComponent { @Input() chat: Chat; diff --git a/src/app/components/chat/conversation/conversation.component.css b/src/app/components/chat/conversation/conversation.component.css index 029551ff..a0a80996 100644 --- a/src/app/components/chat/conversation/conversation.component.css +++ b/src/app/components/chat/conversation/conversation.component.css @@ -1,7 +1,8 @@ -.c-img{ - max-width: 100%; border-radius: 10px; +.c-img { + max-width: 100%; + border-radius: 10px; } -.c-video -{ - max-width: 100%; border-radius: 10px; +.c-video { + max-width: 100%; + border-radius: 10px; } diff --git a/src/app/components/chat/conversation/conversation.component.html b/src/app/components/chat/conversation/conversation.component.html index e96c2d69..16cf06fc 100644 --- a/src/app/components/chat/conversation/conversation.component.html +++ b/src/app/components/chat/conversation/conversation.component.html @@ -176,33 +176,44 @@ >
- - @if ( - last || - chat.messages[i + 1].isMine !== message.isMine - ) { + class="relative max-w-3/4 rounded-lg px-2 py-2" + [ngClass]="{ + 'bg-gray-400 text-blue-50': + message.isMine, + 'bg-gray-500 text-gray-50': + !message.isMine, + }" + > + + @if ( + last || + chat.messages[i + 1].isMine !== + message.isMine + ) { +
+ +
+ } +
- -
- } - -
-
+ class="min-w-4 whitespace-normal break-words leading-5" + [innerHTML]=" + parseContent(message.value) + " + >
+
@if ( @@ -237,7 +248,7 @@ class="flex items-end border-t bg-gray-50 p-4 dark:bg-transparent" >
- + + + + + + + + + +
+
+
+ +
+
+ + + + + +
+
+
+
+ Card cover image + Card cover image + Card cover image + Card cover image +
+ โšก {{ event.zapCount }} zap +
+
+ +
+ + +
+
+ + +
+
+
+ {{
+                    currentUserMetadata?.display_name ||
+                        currentUserMetadata?.name ||
+                        'Avatar'
+                }} + + + + + + +
+
+
+ +
+ +
+ + +
+ +
+
+ + +
+
+
+ {{ reply.username }} +
+ + {{ reply.username }}: + {{ reply.content }} + +
+ Like + Reply + Hide replies + + {{getTimeFromNow(reply) }} +
+
+
+ +
+
+ + +
+ + +
+
+ Loading events... +
+ + +
+ +
No more events to load.
diff --git a/src/app/components/event-list/event-list.component.scss b/src/app/components/event-list/event-list.component.scss new file mode 100644 index 00000000..7d8519d4 --- /dev/null +++ b/src/app/components/event-list/event-list.component.scss @@ -0,0 +1,158 @@ +:host { + display: block; + max-width: 600px; + margin: 0 auto; + font-family: 'Arial', sans-serif; +} + +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0; + + .spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: #009fb5; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +} + +.event-list { + list-style: none; + padding: 0; + margin: 20px 0; + + .event-item { + background-color: #ffffff; + border-radius: 10px; + padding: 15px; + margin-bottom: 10px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transition: box-shadow 0.3s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } + + .event-header { + display: flex; + align-items: center; + + .profile-picture { + width: 40px; + height: 40px; + border-radius: 50%; + margin-right: 10px; + border: 2px solid #009fb5; + } + + .event-info { + .username { + font-weight: bold; + color: #333; + } + + .timestamp { + font-size: 0.9em; + color: #888; + } + } + } + + .event-content { + margin: 10px 0; + font-size: 1.1em; + color: #555; + } + + .event-actions { + display: flex; + gap: 10px; + margin-top: 10px; + + button { + background-color: transparent; + border: none; + cursor: pointer; + color: #009fb5; + font-size: 1.1em; + transition: color 0.2s ease; + + &:hover { + color: #007f91; + } + + &:disabled { + color: #999; + cursor: not-allowed; + } + } + } + + .event-replies { + margin-top: 15px; + border-top: 1px solid #e0e0e0; + padding-top: 10px; + + ul { + list-style: none; + padding: 0; + + .reply-item { + margin: 5px 0; + font-size: 0.9em; + + .reply-username { + font-weight: bold; + color: #009fb5; + } + + .reply-content { + color: #555; + } + } + } + } + } +} + +.no-more-events { + text-align: center; + color: #555; + margin: 20px 0; + font-size: 1.1em; +} + +.load-more-btn { + display: block; + width: 100%; + padding: 10px; + font-size: 1.1em; + background-color: #009fb5; + color: #ffffff; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background-color 0.3s ease; + + &:hover { + background-color: #007f91; + } + + &:focus { + outline: none; + } +} diff --git a/src/app/components/event-list/event-list.component.ts b/src/app/components/event-list/event-list.component.ts new file mode 100644 index 00000000..d871185a --- /dev/null +++ b/src/app/components/event-list/event-list.component.ts @@ -0,0 +1,252 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { Observable, Subscription } from 'rxjs'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { PaginatedEventService } from 'app/services/event.service'; +import { NewEvent } from 'app/types/NewEvent'; +import { AngorCardComponent } from '@angor/components/card'; +import { TextFieldModule } from '@angular/cdk/text-field'; +import { NgClass, CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterLink } from '@angular/router'; +import { PickerComponent } from '@ctrl/ngx-emoji-mart'; +import { QRCodeModule } from 'angularx-qrcode'; +import { SafeUrlPipe } from 'app/shared/pipes/safe-url.pipe'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; + +@Component({ + selector: 'app-event-list', + templateUrl: './event-list.component.html', + styleUrls: ['./event-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ + RouterLink, + AngorCardComponent, + MatIconModule, + MatButtonModule, + MatMenuModule, + MatFormFieldModule, + MatInputModule, + TextFieldModule, + MatDividerModule, + MatTooltipModule, + NgClass, + CommonModule, + FormsModule, + QRCodeModule, + PickerComponent, + MatSlideToggle, + SafeUrlPipe, + MatProgressSpinnerModule, + InfiniteScrollModule, + ] +}) +export class EventListComponent implements OnInit, OnDestroy { + @Input() pubkeys: string[] = []; + @Input() currentUserMetadata: any; + + events$: Observable; + eventStates: { showEmojiPicker: boolean; comment: string }[] = []; + subscriptions: Subscription[] = []; + + isLoading = false; + noMoreEvents = false; + + constructor( + private paginatedEventService: PaginatedEventService, + private changeDetectorRef: ChangeDetectorRef, + private sanitizer: DomSanitizer + ) { + this.events$ = this.paginatedEventService.getEventStream(); + } + + ngOnInit(): void { + this.resetAll(); + } + + + + subscribeToEvents(): void { + this.unsubscribeAll(); + + + if (!this.pubkeys || this.pubkeys.length === 0) { + console.warn('No public keys provided'); + return; + } + + + this.paginatedEventService.subscribeToEvents(this.pubkeys) + .then(() => { + console.log('Subscribed to events for the new user.'); + }) + .catch(error => { + console.error('Error subscribing to events:', error); + }); + + + const eventSub = this.events$.subscribe(events => { + const relevantEvents = events.filter(event => this.pubkeys.includes(event.pubkey)); + + this.eventStates = relevantEvents.map(() => ({ + showEmojiPicker: false, + comment: '' + })); + + this.changeDetectorRef.markForCheck(); + }); + + this.subscriptions.push(eventSub); + } + + + + resetAll(): void { + this.unsubscribeAll(); + this.clearComponentState(); + this.paginatedEventService.clearEvents(); + this.subscribeToEvents(); + this.loadInitialEvents(); + } + + + + + + + unsubscribeAll(): void { + this.subscriptions.forEach(sub => sub.unsubscribe()); + this.subscriptions = []; + } + + clearComponentState(): void { + this.eventStates = []; + this.isLoading = false; + this.noMoreEvents = false; + this.changeDetectorRef.markForCheck(); + } + + + loadInitialEvents(): void { + if (this.pubkeys.length === 0) { + console.warn('No pubkeys provided'); + return; + } + + this.isLoading = true; + this.paginatedEventService.loadMoreEvents(this.pubkeys).finally(() => { + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + }); + } + + loadMoreEvents(): void { + if (!this.isLoading && !this.noMoreEvents) { + this.isLoading = true; + this.paginatedEventService.loadMoreEvents(this.pubkeys).finally(() => { + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + }); + } + } + + + + + + getComment(index: number): string { + return this.eventStates[index]?.comment || ''; + } + + setComment(index: number, value: string): void { + if (this.eventStates[index]) { + this.eventStates[index].comment = value; + } + } + + getSanitizedContent(content: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(content); + } + + sendLike(event: NewEvent): void { + if (!event.likedByMe) { + this.paginatedEventService.sendLikeEvent(event).then(() => { + event.likedByMe = true; + event.likeCount++; + this.changeDetectorRef.markForCheck(); + }).catch(error => console.error('Failed to send like:', error)); + } + } + + toggleLike(event: NewEvent): void { + this.sendLike(event); + } + + toggleCommentEmojiPicker(index: number): void { + this.eventStates[index].showEmojiPicker = !this.eventStates[index].showEmojiPicker; + } + + addEmojiToComment(event: any, index: number): void { + this.eventStates[index].comment += event.emoji.native; + this.eventStates[index].showEmojiPicker = false; + } + + sendComment(event: NewEvent, index: number): void { + const comment = this.eventStates[index].comment; + if (comment.trim() !== '') { + this.paginatedEventService.sendReplyEvent(event, comment).then(() => { + this.eventStates[index].comment = ''; + this.changeDetectorRef.markForCheck(); + }); + } + } + + trackById(index: number, item: NewEvent): string { + return item.id; + } + + ngOnDestroy(): void { + this.unsubscribeAll(); + } + + getTimeFromNow(event: NewEvent): string { + return event.fromNow; + } + + parseContent(content: string): SafeHtml { + const urlRegex = /(https?:\/\/[^\s]+)/g; + const cleanedContent = content.replace(/["]+/g, ''); + const parsedContent = cleanedContent + .replace(urlRegex, (url) => { + if (url.match(/\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/) != null) { + return `Image`; + } else if (url.match(/\.(mp4|webm)$/) != null) { + return ``; + } else if (url.match(/(youtu\.be\/|youtube\.com\/watch\?v=)/)) { + let videoId; + if (url.includes('youtu.be/')) { + videoId = url.split('youtu.be/')[1]; + } else if (url.includes('watch?v=')) { + const urlParams = new URLSearchParams(url.split('?')[1]); + videoId = urlParams.get('v'); + } + return ``; + } else { + return `${url}`; + } + }) + .replace(/\n/g, '
'); + + return this.sanitizer.bypassSecurityTrustHtml(parsedContent); + } +} diff --git a/src/app/components/explore/explore.component.html b/src/app/components/explore/explore.component.html index 82d53274..e75c5876 100644 --- a/src/app/components/explore/explore.component.html +++ b/src/app/components/explore/explore.component.html @@ -1,20 +1,37 @@
-
+
- - + +

Explore Projects

-
+
Whatโ€™s your next investment?
-
+
Check out our projects and find your next investment opportunity.
@@ -23,110 +40,188 @@

Explore Projects

-
+
-
+
-
- - - +
+ + + - -
- + Hide completed
-
+
- Card cover image + " + onerror="this.onerror=null; this.src='/images/pages/profile/cover.jpg';" + alt="Card cover image" + />
- Project logo + " + onerror="this.onerror=null; this.src='/images/avatars/avatar-placeholder.png';" + alt="Project logo" + />
@if (project.displayName || project.name) { -
- {{ - project.displayName || - project.nostrPubKey - }} -
+ " + > + {{ + project.displayName || + project.nostrPubKey + }} +
} @if ( - !project.name && !project.displayName + !project.name && !project.displayName ) { -
- {{ - project.displayName || - project.nostrPubKey - }} -
+
+ {{ + project.displayName || + project.nostrPubKey + }} +
} -
+
{{ - project.about || - 'No description available' + project.about || + 'No description available' }}
@if (project.displayName || project.name) { -
- -
+ " + > + +
}

-
+
{{ project.totalInvestmentsCount || 0 }} investors
- + - Investor avatar {{ i + 1 }} + '-ml-3': + project.totalInvestmentsCount > + 1 && i > 0, + }" + [src]=" + 'images/avatars/avatar-placeholder.png' + " + alt="Investor avatar {{ + i + 1 + }}" + />
@@ -135,18 +230,32 @@

Explore Projects

- -
- -
+ +
+ +
No project
-
-
diff --git a/src/app/components/explore/explore.component.ts b/src/app/components/explore/explore.component.ts index a54cfa26..76721df5 100644 --- a/src/app/components/explore/explore.component.ts +++ b/src/app/components/explore/explore.component.ts @@ -1,7 +1,19 @@ -import { Component, OnInit, OnDestroy, ViewEncapsulation, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core'; -import { Router } from '@angular/router'; -import { ProjectsService } from '../../services/projects.service'; -import { StateService } from '../../services/state.service'; +import { AngorCardComponent } from '@angor/components/card'; +import { AngorFindByKeyPipe } from '@angor/pipes/find-by-key'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { + CommonModule, + I18nPluralPipe, + NgClass, + PercentPipe, +} from '@angular/common'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewEncapsulation, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -11,18 +23,16 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { RouterLink } from '@angular/router'; -import { AngorCardComponent } from '@angor/components/card'; -import { AngorFindByKeyPipe } from '@angor/pipes/find-by-key'; -import { CdkScrollable } from '@angular/cdk/scrolling'; -import { NgClass, PercentPipe, I18nPluralPipe, CommonModule } from '@angular/common'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { Router, RouterLink } from '@angular/router'; +import { Project } from 'app/interface/project.interface'; +import { IndexedDBService } from 'app/services/indexed-db.service'; import { MetadataService } from 'app/services/metadata.service'; import { Subject, takeUntil } from 'rxjs'; -import { IndexedDBService } from 'app/services/indexed-db.service'; -import { Project } from 'app/interface/project.interface'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { Contact } from '../chat/chat.types'; +import { ProjectsService } from '../../services/projects.service'; +import { StateService } from '../../services/state.service'; import { ChatService } from '../chat/chat.service'; +import { Contact } from '../chat/chat.types'; @Component({ selector: 'explore', @@ -30,10 +40,23 @@ import { ChatService } from '../chat/chat.service'; templateUrl: './explore.component.html', encapsulation: ViewEncapsulation.None, imports: [ - MatButtonModule, RouterLink, MatIconModule, AngorCardComponent, - CdkScrollable, MatFormFieldModule, MatSelectModule, MatOptionModule, - MatInputModule, MatSlideToggleModule, NgClass, MatTooltipModule, - MatProgressBarModule, AngorFindByKeyPipe, PercentPipe, I18nPluralPipe, CommonModule + MatButtonModule, + RouterLink, + MatIconModule, + AngorCardComponent, + CdkScrollable, + MatFormFieldModule, + MatSelectModule, + MatOptionModule, + MatInputModule, + MatSlideToggleModule, + NgClass, + MatTooltipModule, + MatProgressBarModule, + AngorFindByKeyPipe, + PercentPipe, + I18nPluralPipe, + CommonModule, ], }) export class ExploreComponent implements OnInit, OnDestroy { @@ -45,7 +68,6 @@ export class ExploreComponent implements OnInit, OnDestroy { filteredProjects: Project[] = []; showCloseSearchButton: boolean = false; - constructor( private projectService: ProjectsService, private router: Router, @@ -55,7 +77,7 @@ export class ExploreComponent implements OnInit, OnDestroy { private changeDetectorRef: ChangeDetectorRef, private sanitizer: DomSanitizer, private _chatService: ChatService - ) { } + ) {} async ngOnInit(): Promise { this.loadInitialProjects(); @@ -96,21 +118,26 @@ export class ExploreComponent implements OnInit, OnDestroy { this.filteredProjects = [...this.projects]; this.stateService.setProjects(this.projects); - const pubkeys = projects.map(p => p.nostrPubKey); + const pubkeys = projects.map((p) => p.nostrPubKey); await this.loadMetadataForProjects(pubkeys); - } catch (error) { this.handleError('Error fetching projects from service'); } } private subscribeToMetadataUpdates(): void { - this.indexedDBService.getMetadataStream() + this.indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata: any) => { if (updatedMetadata) { - const projectToUpdate = this.projects.find(p => p.nostrPubKey === updatedMetadata.pubkey); + const projectToUpdate = this.projects.find( + (p) => p.nostrPubKey === updatedMetadata.pubkey + ); if (projectToUpdate) { - this.updateProjectMetadata(projectToUpdate, updatedMetadata.metadata); + this.updateProjectMetadata( + projectToUpdate, + updatedMetadata.metadata + ); } } }); @@ -118,14 +145,15 @@ export class ExploreComponent implements OnInit, OnDestroy { private getProjectsWithoutMetadata(): string[] { return this.projects - .filter(project => !project.displayName || !project.about) - .map(project => project.nostrPubKey); + .filter((project) => !project.displayName || !project.about) + .map((project) => project.nostrPubKey); } private async loadMetadataForProjects(pubkeys: string[]): Promise { const metadataPromises = pubkeys.map(async (pubkey) => { // Check cache first - const cachedMetadata = await this.indexedDBService.getUserMetadata(pubkey); + const cachedMetadata = + await this.indexedDBService.getUserMetadata(pubkey); if (cachedMetadata) { return { pubkey, metadata: cachedMetadata }; } @@ -136,13 +164,15 @@ export class ExploreComponent implements OnInit, OnDestroy { // Filter out nulls (which represent pubkeys without cached metadata) const missingPubkeys = metadataResults - .filter(result => result === null) + .filter((result) => result === null) .map((_, index) => pubkeys[index]); // Update projects that have cached metadata - metadataResults.forEach(result => { + metadataResults.forEach((result) => { if (result && result.metadata) { - const project = this.projects.find(p => p.nostrPubKey === result.pubkey); + const project = this.projects.find( + (p) => p.nostrPubKey === result.pubkey + ); if (project) { this.updateProjectMetadata(project, result.metadata); } @@ -151,80 +181,103 @@ export class ExploreComponent implements OnInit, OnDestroy { // Fetch metadata for pubkeys that are not cached if (missingPubkeys.length > 0) { - await this.metadataService.fetchMetadataForMultipleKeys(missingPubkeys) + await this.metadataService + .fetchMetadataForMultipleKeys(missingPubkeys) .then((metadataList: any[]) => { - metadataList.forEach(metadata => { - const project = this.projects.find(p => p.nostrPubKey === metadata.pubkey); + metadataList.forEach((metadata) => { + const project = this.projects.find( + (p) => p.nostrPubKey === metadata.pubkey + ); if (project) { this.updateProjectMetadata(project, metadata); } }); this.changeDetectorRef.detectChanges(); }) - .catch(error => { - console.error('Error fetching metadata for projects:', error); + .catch((error) => { + console.error( + 'Error fetching metadata for projects:', + error + ); }); } } - - async loadProjects(): Promise { - if (this.loading || this.errorMessage === 'No more projects found') return; + if (this.loading || this.errorMessage === 'No more projects found') + return; this.loading = true; - this.projectService.fetchProjects().then(async (projects: Project[]) => { - if (projects.length === 0 && this.projects.length === 0) { - this.errorMessage = 'No projects found'; - } else if (projects.length === 0) { - this.errorMessage = 'No more projects found'; - } else { - this.projects = [...this.projects, ...projects]; - this.filteredProjects = [...this.projects]; + this.projectService + .fetchProjects() + .then(async (projects: Project[]) => { + if (projects.length === 0 && this.projects.length === 0) { + this.errorMessage = 'No projects found'; + } else if (projects.length === 0) { + this.errorMessage = 'No more projects found'; + } else { + this.projects = [...this.projects, ...projects]; + this.filteredProjects = [...this.projects]; - const pubkeys = projects.map(project => project.nostrPubKey); + const pubkeys = projects.map( + (project) => project.nostrPubKey + ); - await this.loadMetadataForProjects(pubkeys); + await this.loadMetadataForProjects(pubkeys); - this.stateService.setProjects(this.projects); + this.stateService.setProjects(this.projects); - this.projects.forEach(project => this.subscribeToProjectMetadata(project)); - } - this.loading = false; - this.changeDetectorRef.detectChanges(); - }).catch((error: any) => { - console.error('Error fetching projects:', error); - this.errorMessage = 'Error fetching projects. Please try again later.'; - this.loading = false; - this.changeDetectorRef.detectChanges(); - }); + this.projects.forEach((project) => + this.subscribeToProjectMetadata(project) + ); + } + this.loading = false; + this.changeDetectorRef.detectChanges(); + }) + .catch((error: any) => { + console.error('Error fetching projects:', error); + this.errorMessage = + 'Error fetching projects. Please try again later.'; + this.loading = false; + this.changeDetectorRef.detectChanges(); + }); } async loadMetadataForProject(project: Project): Promise { try { - const metadata = await this.metadataService.fetchMetadataWithCache(project.nostrPubKey); + const metadata = await this.metadataService.fetchMetadataWithCache( + project.nostrPubKey + ); if (metadata) { this.updateProjectMetadata(project, metadata); } else { - console.warn(`No metadata found for project ${project.nostrPubKey}`); + console.warn( + `No metadata found for project ${project.nostrPubKey}` + ); } } catch (error) { - console.error(`Error fetching metadata for project ${project.nostrPubKey}:`, error); + console.error( + `Error fetching metadata for project ${project.nostrPubKey}:`, + error + ); } } updateProjectMetadata(project: Project, metadata: any): void { - const updatedProject: Project = { ...project, displayName: metadata.name || '', - about: metadata.about ? metadata.about.replace(/<\/?[^>]+(>|$)/g, '') : '', + about: metadata.about + ? metadata.about.replace(/<\/?[^>]+(>|$)/g, '') + : '', picture: metadata.picture || '', - banner: metadata.banner || '' + banner: metadata.banner || '', }; - const index = this.projects.findIndex(p => p.projectIdentifier === project.projectIdentifier); + const index = this.projects.findIndex( + (p) => p.projectIdentifier === project.projectIdentifier + ); if (index !== -1) { this.projects[index] = updatedProject; this.projects = [...this.projects]; @@ -235,11 +288,18 @@ export class ExploreComponent implements OnInit, OnDestroy { } subscribeToProjectMetadata(project: Project): void { - this.metadataService.getMetadataStream() + this.metadataService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata: any) => { - if (updatedMetadata && updatedMetadata.pubkey === project.nostrPubKey) { - this.updateProjectMetadata(project, updatedMetadata.metadata); + if ( + updatedMetadata && + updatedMetadata.pubkey === project.nostrPubKey + ) { + this.updateProjectMetadata( + project, + updatedMetadata.metadata + ); } }); } @@ -247,7 +307,8 @@ export class ExploreComponent implements OnInit, OnDestroy { goToProjectDetails(project: Project): void { this.loading = true; - this.projectService.fetchAndSaveProjectStats(project.projectIdentifier) + this.projectService + .fetchAndSaveProjectStats(project.projectIdentifier) .then((stats) => { if (stats) { this.navigateToProfile(project.nostrPubKey); @@ -278,17 +339,30 @@ export class ExploreComponent implements OnInit, OnDestroy { const lowerCaseQuery = query.toLowerCase(); - this.filteredProjects = this.projects.filter(project => { + this.filteredProjects = this.projects.filter((project) => { return ( - (project.displayName && project.displayName.toLowerCase().includes(lowerCaseQuery)) || - (project.about && project.about.toLowerCase().includes(lowerCaseQuery)) || - (project.displayName && project.displayName.toLowerCase().includes(lowerCaseQuery)) || - (project.nostrPubKey && project.nostrPubKey.toLowerCase().includes(lowerCaseQuery)) || - (project.projectIdentifier && project.projectIdentifier.toLowerCase().includes(lowerCaseQuery)) + (project.displayName && + project.displayName + .toLowerCase() + .includes(lowerCaseQuery)) || + (project.about && + project.about.toLowerCase().includes(lowerCaseQuery)) || + (project.displayName && + project.displayName + .toLowerCase() + .includes(lowerCaseQuery)) || + (project.nostrPubKey && + project.nostrPubKey + .toLowerCase() + .includes(lowerCaseQuery)) || + (project.projectIdentifier && + project.projectIdentifier + .toLowerCase() + .includes(lowerCaseQuery)) ); }); - this.showCloseSearchButton = this.projects.length > 0 ; + this.showCloseSearchButton = this.projects.length > 0; this.changeDetectorRef.detectChanges(); } @@ -299,9 +373,7 @@ export class ExploreComponent implements OnInit, OnDestroy { this.showCloseSearchButton = false; } - toggleCompleted(event: any): void { - - } + toggleCompleted(event: any): void {} ngOnDestroy(): void { this._unsubscribeAll.next(null); @@ -319,7 +391,9 @@ export class ExploreComponent implements OnInit, OnDestroy { if (url && typeof url === 'string' && this.isImageUrl(url)) { return this.sanitizer.bypassSecurityTrustUrl(url); } else { - const defaultImage = isBanner ? '/images/pages/profile/cover.jpg' : 'images/avatars/avatar-placeholder.png'; + const defaultImage = isBanner + ? '/images/pages/profile/cover.jpg' + : 'images/avatars/avatar-placeholder.png'; return this.sanitizer.bypassSecurityTrustUrl(defaultImage); } } @@ -330,26 +404,34 @@ export class ExploreComponent implements OnInit, OnDestroy { async openChat(publicKey: string): Promise { try { - const metadata = await this.metadataService.fetchMetadataWithCache(publicKey); + const metadata = + await this.metadataService.fetchMetadataWithCache(publicKey); if (metadata) { const contact: Contact = { pubKey: publicKey, name: metadata.name || 'Unknown', - picture: metadata.picture || '/images/avatars/avatar-placeholder.png', + picture: + metadata.picture || + '/images/avatars/avatar-placeholder.png', about: metadata.about || '', - displayName: metadata.displayName || metadata.name || 'Unknown', + displayName: + metadata.displayName || metadata.name || 'Unknown', }; - this._chatService.getChatById(contact.pubKey, contact).subscribe((chat) => { - this.router.navigate(['/chat', contact.pubKey]); - }); + this._chatService + .getChatById(contact.pubKey, contact) + .subscribe((chat) => { + this.router.navigate(['/chat', contact.pubKey]); + }); } else { - console.error('No metadata found for the public key:', publicKey); + console.error( + 'No metadata found for the public key:', + publicKey + ); } } catch (error) { console.error('Error opening chat:', error); } } - } diff --git a/src/app/components/explore/explore.routes.ts b/src/app/components/explore/explore.routes.ts index 83fc2eb2..379f2b9a 100644 --- a/src/app/components/explore/explore.routes.ts +++ b/src/app/components/explore/explore.routes.ts @@ -3,7 +3,7 @@ import { ExploreComponent } from 'app/components/explore/explore.component'; export default [ { - path : '', + path: '', component: ExploreComponent, }, ] as Routes; diff --git a/src/app/components/home/home.component.html b/src/app/components/home/home.component.html index d18fc4a2..72bcaf05 100644 --- a/src/app/components/home/home.component.html +++ b/src/app/components/home/home.component.html @@ -3,10 +3,18 @@

Angor Hub

- Angor Hub is a Nostr client that is customized around the Angor protocol, a decentralized crowdfunding platform. Leveraging the power of Nostr the platform allows you to explore projects that are raising funds using Angor, engage with investors, and connect directly with founders. + Angor Hub is a Nostr client that is customized around the Angor + protocol, a decentralized crowdfunding platform. Leveraging the + power of Nostr the platform allows you to explore projects that + are raising funds using Angor, engage with investors, and + connect directly with founders.

- Whether you're an investor looking for the next big opportunity or a project founder seeking funding, Angor Hub offers the tools you need to succeed. From project pages, secure messaging to group channels, Angor Hub ensures seamless interaction within a decentralized Nostr. + Whether you're an investor looking for the next big opportunity + or a project founder seeking funding, Angor Hub offers the tools + you need to succeed. From project pages, secure messaging to + group channels, Angor Hub ensures seamless interaction within a + decentralized Nostr.

diff --git a/src/app/components/home/home.component.ts b/src/app/components/home/home.component.ts index ff291767..e9476a00 100644 --- a/src/app/components/home/home.component.ts +++ b/src/app/components/home/home.component.ts @@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router'; templateUrl: './home.component.html', encapsulation: ViewEncapsulation.None, standalone: true, - imports: [MatButtonModule, RouterLink, MatIconModule], + imports: [MatButtonModule, RouterLink, MatIconModule ], }) export class LandingHomeComponent { /** diff --git a/src/app/components/profile/profile.component.html b/src/app/components/profile/profile.component.html index c42049bb..bf4af137 100644 --- a/src/app/components/profile/profile.component.html +++ b/src/app/components/profile/profile.component.html @@ -3,147 +3,237 @@
- {{metadata?.display_name || metadata?.name || 'Banner'}} + alt="{{ metadata?.display_name || metadata?.name || 'Banner' }}" + />
-
+
-
+
- {{metadata?.display_name || metadata?.name || 'Avatar'}} + alt="{{ + metadata?.display_name || metadata?.name || '' + }}" + /> - {{metadata?.display_name || metadata?.name || 'Avatar'}} + alt="{{ + metadata?.display_name || metadata?.name || '' + }}" + />
-
-
- {{metadata?.display_name || metadata?.name || 'Unknown User'}} -
-
- {{metadata?.username || metadata?.name}} +
+
+ {{ + metadata?.display_name || + metadata?.name || + 'Unknown User' + }} +
+
+ {{ metadata?.username || metadata?.name }}
- - + -
+
{{ followers.length }} - FOLLOWERS + FOLLOWERS
{{ following.length }} - FOLLOWING + FOLLOWING
- + -
+
-
- - -
- - -
-
-
+
+ See complete about - +
Suggestion
-
- -
-
+
+
- + [matTooltip]=" + suggestions[i * 4 + j]?.metadata?.name + ? suggestions[i * 4 + j].metadata.name + : '' + " + [routerLink]="[ + '/profile', + suggestions[i * 4 + j].pubkey, + ]" + role="button" + />
- - - - + +
- Groups + Last projects
- - + +
- Card cover image + Card cover image
The Port Cafe @@ -196,369 +320,397 @@
Best cafe of the downtown New York
-
+
1.2k followers
-
- Card cover image -
-
- Design House LLC. -
-
- UI/UX, brand and product design -
-
- 957 followers -
-
-
-
- Card cover image -
-
- Crax Laser Tag -
-
- 30% off with group of 6 people -
-
- 342 followers -
-
-
-
- Card cover image -
-
- Roadster Clothing Inc. -
-
- $25 off on orders $500 and over -
-
- 4.7k followers -
-
-
- - - - -
-
News
-
- - - - -
-
-
-
- -
- 20% OFF in your favorite hats shop on next - Friday. -
-
-
- -
- Upcoming meetups within 20 miles. - See - details - -
-
-
- -
- Concerts from your favorite bands available within - 100 miles. - See - details - -
-
-
- -
- - - -
-
- Activity Feed -
-
- - - - -
-
-
-
- Card cover image -
-
- Amelia Edwards commented on John Silverton's - photo -
-
- 4 minutes ago -
-
-
-
- Card cover image -
-
- Lew Silverton changed his profile photo -
-
- 25 minutes ago -
-
-
-
- Card cover image -
-
- Display Name liked your photo -
-
- 3 hours ago -
-
-
-
- Card cover image -
-
- Marleah Eagleston commented on John Silverton's - photo -
-
- Yesterday -
-
-
-
-
-
+
- -
Create Post
+ +
+
Create Post
+ + + Preview + +
- {{currentUserMetadata?.display_name || currentUserMetadata?.name || 'Avatar'}} -
{{currentUserMetadata?.name || 'Unknown User'}}
+ alt="{{ + currentUserMetadata?.display_name || + currentUserMetadata?.name || + 'Avatar' + }}" + /> +
+ {{ currentUserMetadata?.name || 'Unknown User' }} +
- - + +
-
- - - - +
+ +
+ + + + + + + + + + + + + +
+ + - - - - - - -
- - + +
- Card cover image + {{
+                            currentUserMetadata?.display_name ||
+                                currentUserMetadata?.name ||
+                                'Avatar'
+                        }}
- Caroline Lundu - 29 minutes ago -
-
+
- Look at that sky! I so want to be there.. Can we arrange a - trip? Is that a possibility? Please!!! + + {{ eventInput.nativeElement.value }}
- Card cover image -
- -
+
- -

-
+
- Card cover image - Card cover image - Card cover image - Card cover image + Card cover image + Card cover image + Card cover image + Card cover image
- You and 24 more liked this + You and 4 more liked this
-
@@ -567,781 +719,80 @@
- - {{currentUserMetadata?.display_name || currentUserMetadata?.name || 'Avatar'}} - - - + alt="{{ + currentUserMetadata?.display_name || + currentUserMetadata?.name || + 'Avatar' + }}" + /> + + +
-
- - - -
-
-
-
-
-
- Card cover image -
- - Rutherford Brannan Oh, Iโ€™m in.. - Letโ€™s arrange a trip for the next - weekend if you want! - -
- Like - Reply - Hide replies - - 17 min -
-
-
-
- Card cover image -
- - Caroline Lundu Yes!! Let's talk - about it on lunch! - -
- Like - Reply - - 15 min -
-
-
-
- Card cover image -
- - Barbara Cotilla Count me in !!! - -
- Like - Reply - - 12 min -
-
-
-
- Card cover image -
- - Alan Marti The color of the sky - doesnโ€™t look natural at all, do you - really think this is natural? Iโ€™d say - Photoshop! Your trip isn't going to - worth it since you won't be seeing this - exact sky. - -
- Like - Reply - Hide replies - - 24 min -
-
-
-
- Card cover image -
- - Caroline Lundu Hey, Alan! You - must be fun at parties! - -
- Like - Reply - - 22 min -
-
-
-
- Card cover image -
- - Alan Marti Caroline, I'm telling - the truth, and if you cannot stand the - truth, maybe we shouldn't be friends - anymore... - -
- Like - Reply - - 20 min -
-
-
-
- Card cover image -
- - Caroline Lundu Dude! Relax! I'm - just messing with you... - -
- Like - Reply - - 18 min -
-
-
-
- Card cover image -
- - Alan Marti Sorry! I had a bad - morning, let's talk about this in couple - hours, I need to relax a bit :( - -
- Like - Reply - - 16 min -
-
-
-
- Card cover image -
- - Marleah Eagleston Count me in, - too! - -
- Like - Reply - - 34 min -
+
+
+ +
+
-
+ + +
+
- - -
- Card cover image -
- Caroline Lundu - 29 minutes ago -
- - - - - - - - - - -
-
-

- We'll put a happy little sky in here. We touch the - canvas, the canvas takes what it wants. A little happy - sunlight shining through there. Let's build some happy - little clouds up here. I was blessed with a very steady - hand; and it comes in very handy when you're doing these - little delicate things. This is the fun part. -

-

- Isn't it great to do something you can't fail at? Little - trees and bushes grow however makes them happy. Trees - get lonely too, so we'll give him a little friend. There - are no mistakes. You can fix anything that happens. -

-
-
- - - -
-
-
-
- Card cover image - Card cover image - Card cover image - Card cover image -
- You and 24 more liked this -
-
- -
- - -
-
-
+ - - -
- Card cover image -
- Marleah Eagleston - 29 minutes ago -
- - - - - - - - - - -
-
- Look at that sky! I so want to be there.. Can we arrange a - trip? Is that a possibility? Please!!! -
-
-
- Card cover image -
-
-
- Card cover image -
-
- Card cover image -
-
-
-
- - - -
-
-
-
- Card cover image - Card cover image - Card cover image - Card cover image -
- You and 24 more liked this -
-
- -
- - -
-
-
- - - -
- Card cover image -
- Caroline Lundu - 29 minutes ago -
- - - - - - - - - - -
-
- Hey!! I never saw this one, it was amazing.. I think Iโ€™m - going to buy myself a set and try his technique at home! -
-
-
-
- Card cover image -
-
-
- Take a look behind the scenes of Rob Boss - episodes -
-
- We'll put a happy little sky in here. We touch - the canvas, the canvas takes what it wants. A - little happy sunlight shining through there. -
-
- example.com -
-
-
-
-
- - - -
-
-
-
- Card cover image - Card cover image - Card cover image - Card cover image -
- You and 24 more liked this -
-
- -
- - -
-
-
- - - -
- Card cover image -
- Marleah Eagleston - 29 minutes ago -
- - - - - - - - - - -
-
- Look at that sky! I so want to be there.. Can we arrange a - trip? Is that a possibility? Please!!! -
-
-
- Card cover image -
-
- Card cover image -
-
-
- - - -
-
-
-
- Card cover image - Card cover image - Card cover image - Card cover image -
- You and 24 more liked this -
-
- -
- - -
-
-
- - - -
- Card cover image -
- Caroline Lundu - 29 minutes ago -
- - - - - - - - - - -
-
- Hey!! I never saw this episode, it was amazing.. I think Iโ€™m - going to buy myself a set and try his technique at home! -
-
-
-
- Card cover image -
-
-
- Rob Boss - Season 09 Episode 04 -
-
- We'll put a happy little sky in here. We touch - the canvas, the canvas takes what it wants. A - little happy sunlight shining through there. -
-
- example.com -
-
-
-
-
- - - -
-
-
-
- Card cover image - Card cover image - Card cover image - Card cover image -
- You and 24 more liked this -
-
- -
- - -
-
-
diff --git a/src/app/components/profile/profile.component.scss b/src/app/components/profile/profile.component.scss new file mode 100644 index 00000000..c117835b --- /dev/null +++ b/src/app/components/profile/profile.component.scss @@ -0,0 +1,51 @@ +.emoji-picker-container-global { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 9999; + width: 350px; + max-width: 100%; +} +.heart-beat { + animation: heartBeatAnimation 0.3s ease-in-out; +} + +@keyframes heartBeatAnimation { + 0% { + transform: scale(1); + } + 30% { + transform: scale(2); + } + 60% { + transform: scale(1); + } + 100% { + transform: scale(1); + } +} +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + margin: 20px 0; + + .spinner { + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: #009fb5; + border-radius: 50%; + width: 30px; + height: 30px; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +} diff --git a/src/app/components/profile/profile.component.ts b/src/app/components/profile/profile.component.ts index 66287f25..bd02a7e7 100644 --- a/src/app/components/profile/profile.component.ts +++ b/src/app/components/profile/profile.component.ts @@ -1,45 +1,63 @@ +import { AngorCardComponent } from '@angor/components/card'; +import { AngorConfigService } from '@angor/services/config'; +import { AngorConfirmationService } from '@angor/services/confirmation'; import { TextFieldModule } from '@angular/cdk/text-field'; import { CommonModule, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ViewEncapsulation, + ElementRef, + OnDestroy, OnInit, - OnDestroy - + ViewChild, + ViewEncapsulation, } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { AngorCardComponent } from '@angor/components/card'; -import { SignerService } from 'app/services/signer.service'; -import { MetadataService } from 'app/services/metadata.service'; -import { Subject, takeUntil } from 'rxjs'; -import { IndexedDBService } from 'app/services/indexed-db.service'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; -import { SocialService } from 'app/services/social.service'; -import { MatDialog } from '@angular/material/dialog'; -import { LightningInvoice, LightningResponse } from 'app/types/post'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { LightningService } from 'app/services/lightning.service'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { PickerComponent } from '@ctrl/ngx-emoji-mart'; import { bech32 } from '@scure/base'; -import { FormsModule } from '@angular/forms'; import { QRCodeModule } from 'angularx-qrcode'; -import { Clipboard } from '@angular/cdk/clipboard'; -import { SendDialogComponent } from './zap/send-dialog/send-dialog.component'; +import { PaginatedEventService } from 'app/services/event.service'; +import { IndexedDBService } from 'app/services/indexed-db.service'; +import { LightningService } from 'app/services/lightning.service'; +import { MetadataService } from 'app/services/metadata.service'; +import { SignerService } from 'app/services/signer.service'; +import { SocialService } from 'app/services/social.service'; +import { SafeUrlPipe } from 'app/shared/pipes/safe-url.pipe'; +import { Paginator } from 'app/shared/utils'; +import { LightningInvoice, LightningResponse, Post } from 'app/types/post'; +import { InfiniteScrollModule } from 'ngx-infinite-scroll'; +import { NostrEvent } from 'nostr-tools'; +import { Subject, takeUntil } from 'rxjs'; +import { EventListComponent } from '../event-list/event-list.component'; import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.component'; +import { SendDialogComponent } from './zap/send-dialog/send-dialog.component'; + +interface Chip { + color?: string; + selected?: string; + name: string; +} @Component({ selector: 'profile', templateUrl: './profile.component.html', - encapsulation: ViewEncapsulation.None, + styleUrls: ['./profile.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, standalone: true, imports: [ RouterLink, @@ -56,28 +74,54 @@ import { ReceiveDialogComponent } from './zap/receive-dialog/receive-dialog.comp CommonModule, FormsModule, QRCodeModule, + PickerComponent, + MatSlideToggle, + + SafeUrlPipe, + MatProgressSpinnerModule, + InfiniteScrollModule, + EventListComponent, ], }) export class ProfileComponent implements OnInit, OnDestroy { + @ViewChild('eventInput', { static: false }) eventInput: ElementRef; + @ViewChild('commentInput') commentInput: ElementRef; + + darkMode: boolean = false; isLoading: boolean = true; errorMessage: string | null = null; metadata: any; currentUserMetadata: any; private _unsubscribeAll: Subject = new Subject(); - private userPubKey; - private routePubKey; + public currentUserPubKey: string; + public routePubKey; followers: any[] = []; following: any[] = []; allPublicKeys: string[] = []; - suggestions: { pubkey: string, metadata: any }[] = []; + suggestions: { pubkey: string; metadata: any }[] = []; isCurrentUserProfile: Boolean = false; isFollowing = false; + showEmojiPicker = false; + showCommentEmojiPicker = false; lightningResponse: LightningResponse | null = null; lightningInvoice: LightningInvoice | null = null; sats: string; paymentInvoice: string = ''; invoiceAmount: string = '?'; + isLiked = false; + isPreview = false; + posts: Post[] = []; + likes: any[] = []; + + paginator: Paginator; + + myLikes: NostrEvent[] = []; + myLikedNoteIds: string[] = []; + + isLoadingPosts: boolean = true; + noEventsMessage: string = ''; + loadingTimeout: any; constructor( @@ -90,52 +134,81 @@ export class ProfileComponent implements OnInit, OnDestroy { private _socialService: SocialService, private snackBar: MatSnackBar, private lightning: LightningService, - private _dialog: MatDialog // Add MatDialog here - - ) { } + private _dialog: MatDialog, + private _angorConfigService: AngorConfigService, + private _angorConfirmationService: AngorConfirmationService, + private eventService: PaginatedEventService + ) { + let baseTimeDiff = 12000; + let since = 0; + + this.paginator = new Paginator(0, since, (baseTimeDiff = baseTimeDiff)); + } ngOnInit(): void { - + this._angorConfigService.config$.subscribe((config) => { + if (config.scheme === 'auto') { + this.detectSystemTheme(); + } else { + this.darkMode = config.scheme === 'dark'; + } + }); this._route.paramMap.subscribe((params) => { const routePubKey = params.get('pubkey'); this.routePubKey = routePubKey; - const userPubKey = this._signerService.getPublicKey(); - this.isCurrentUserProfile = routePubKey === userPubKey; - const pubKeyToLoad = routePubKey || userPubKey; - this.loadProfile(pubKeyToLoad); + const currentUserPubKey = this._signerService.getPublicKey(); + this.currentUserPubKey = currentUserPubKey; + if (routePubKey || currentUserPubKey) { + this.isCurrentUserProfile = routePubKey === currentUserPubKey; + } + + this.routePubKey = routePubKey || currentUserPubKey; + this.loadProfile(this.routePubKey); if (!routePubKey) { this.isCurrentUserProfile = true; } this.loadCurrentUserProfile(); + + }); - this._indexedDBService.getMetadataStream() + this._indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === this.currentUserPubKey + ) { this.currentUserMetadata = updatedMetadata.metadata; this._changeDetectorRef.detectChanges(); } }); if (this.routePubKey) { - this._indexedDBService.getMetadataStream() + this._indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === this.routePubKey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === this.routePubKey + ) { this.metadata = updatedMetadata.metadata; this._changeDetectorRef.detectChanges(); } }); } - this._socialService.getFollowersObservable() + this._socialService + .getFollowersObservable() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((event) => { this.followers.push(event.pubkey); this._changeDetectorRef.detectChanges(); }); - this._socialService.getFollowingObservable() + this._socialService + .getFollowingObservable() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((event) => { const tags = event.tags.filter((tag) => tag[0] === 'p'); @@ -144,8 +217,6 @@ export class ProfileComponent implements OnInit, OnDestroy { }); this._changeDetectorRef.detectChanges(); }); - - this.updateSuggestionList(); } ngOnDestroy(): void { @@ -153,6 +224,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this._unsubscribeAll.complete(); } + async loadProfile(publicKey: string): Promise { this.isLoading = true; this.errorMessage = null; @@ -160,6 +232,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this.metadata = null; this.followers = []; this.following = []; + this._changeDetectorRef.detectChanges(); if (!publicKey) { @@ -170,27 +243,20 @@ export class ProfileComponent implements OnInit, OnDestroy { } try { + const userMetadata = await this._metadataService.fetchMetadataWithCache(publicKey); if (userMetadata) { this.metadata = userMetadata; this._changeDetectorRef.detectChanges(); } - await this._socialService.getFollowers(publicKey); + + this.followers = await this._socialService.getFollowers(publicKey); const currentUserPubKey = this._signerService.getPublicKey(); this.isFollowing = this.followers.includes(currentUserPubKey); - await this._socialService.getFollowing(publicKey); - - this._metadataService.getMetadataStream() - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === publicKey) { - this.metadata = updatedMetadata; - this._changeDetectorRef.detectChanges(); - } - }); - + this.following = await this._socialService.getFollowing(publicKey); + this._changeDetectorRef.detectChanges(); } catch (error) { console.error('Failed to load profile data:', error); this.errorMessage = 'Failed to load profile data. Please try again later.'; @@ -201,25 +267,23 @@ export class ProfileComponent implements OnInit, OnDestroy { } } + private async loadCurrentUserProfile(): Promise { try { this.currentUserMetadata = null; + this.currentUserPubKey = this._signerService.getPublicKey(); + + + const currentUserMetadata = await this._metadataService.fetchMetadataWithCache( + this.currentUserPubKey + ); - this.userPubKey = this._signerService.getPublicKey(); - const currentUserMetadata = await this._metadataService.fetchMetadataWithCache(this.userPubKey); if (currentUserMetadata) { this.currentUserMetadata = currentUserMetadata; - - this._changeDetectorRef.detectChanges(); } - this._metadataService.getMetadataStream() - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === this.userPubKey) { - this.currentUserMetadata = updatedMetadata; - this._changeDetectorRef.detectChanges(); - } - }); + + + this._changeDetectorRef.detectChanges(); } catch (error) { console.error('Failed to load profile data:', error); this.errorMessage = 'Failed to load profile data. Please try again later.'; @@ -229,15 +293,8 @@ export class ProfileComponent implements OnInit, OnDestroy { } } - private updateSuggestionList(): void { - this._indexedDBService.getSuggestionUsers().then((suggestions) => { - this.suggestions = suggestions; - this._changeDetectorRef.detectChanges(); - }).catch((error) => { - console.error('Error updating suggestion list:', error); - }); - } + getSafeUrl(url: string): SafeUrl { return this._sanitizer.bypassSecurityTrustUrl(url); @@ -246,7 +303,7 @@ export class ProfileComponent implements OnInit, OnDestroy { async toggleFollow(): Promise { try { const userPubKey = this._signerService.getPublicKey(); - const routePubKey = this.routePubKey || this.userPubKey; + const routePubKey = this.routePubKey || this.currentUserPubKey; if (!routePubKey || !userPubKey) { console.error('Public key missing. Unable to toggle follow.'); @@ -257,7 +314,9 @@ export class ProfileComponent implements OnInit, OnDestroy { await this._socialService.unfollow(routePubKey); console.log(`Unfollowed ${routePubKey}`); - this.followers = this.followers.filter(pubkey => pubkey !== userPubKey); + this.followers = this.followers.filter( + (pubkey) => pubkey !== userPubKey + ); } else { await this._socialService.follow(routePubKey); console.log(`Followed ${routePubKey}`); @@ -268,19 +327,15 @@ export class ProfileComponent implements OnInit, OnDestroy { this.isFollowing = !this.isFollowing; this._changeDetectorRef.detectChanges(); - } catch (error) { console.error('Failed to toggle follow:', error); } } - openSnackBar(message: string, action: string) { this.snackBar.open(message, action, { duration: 1300 }); } - - getLightningInfo() { let lightningAddress = ''; if (this.metadata?.lud06) { @@ -292,19 +347,29 @@ export class ProfileComponent implements OnInit, OnDestroy { const data = new Uint8Array(bech32.fromWords(words)); lightningAddress = new TextDecoder().decode(Uint8Array.from(data)); } else if (this.metadata?.lud16) { - lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16); + lightningAddress = this.lightning.getLightningAddress( + this.metadata.lud16 + ); } if (lightningAddress !== '') { - this.lightning.getLightning(lightningAddress).subscribe((response) => { - this.lightningResponse = response; - if (this.lightningResponse.status === 'Failed') { - this.openSnackBar('Failed to lookup lightning address', 'dismiss'); - } else if (this.lightningResponse.callback) { - this.openZapDialog(); // Open dialog when callback is available - } else { - this.openSnackBar("couldn't find user's lightning address", 'dismiss'); - } - }); + this.lightning + .getLightning(lightningAddress) + .subscribe((response) => { + this.lightningResponse = response; + if (this.lightningResponse.status === 'Failed') { + this.openSnackBar( + 'Failed to lookup lightning address', + 'dismiss' + ); + } else if (this.lightningResponse.callback) { + this.openZapDialog(); + } else { + this.openSnackBar( + "couldn't find user's lightning address", + 'dismiss' + ); + } + }); } else { this.openSnackBar('No lightning address found', 'dismiss'); } @@ -322,7 +387,7 @@ export class ProfileComponent implements OnInit, OnDestroy { this._dialog.open(SendDialogComponent, { width: '405px', maxHeight: '90vh', - data: this.metadata + data: this.metadata, }); } @@ -330,8 +395,95 @@ export class ProfileComponent implements OnInit, OnDestroy { this._dialog.open(ReceiveDialogComponent, { width: '405px', maxHeight: '90vh', - data: this.metadata + data: this.metadata, }); } + toggleLike() { + this.isLiked = !this.isLiked; + + if (this.isLiked) { + setTimeout(() => { + this.isLiked = false; + this.isLiked = true; + }, 300); + } + } + + addEmoji(event: any) { + this.eventInput.nativeElement.value += event.emoji.native; + this.showEmojiPicker = false; + } + + toggleEmojiPicker() { + this.showCommentEmojiPicker = false; + this.showEmojiPicker = !this.showEmojiPicker; + } + + addEmojiTocomment(event: any) { + this.commentInput.nativeElement.value += event.emoji.native; + this.showCommentEmojiPicker = false; + } + + toggleCommentEmojiPicker() { + this.showEmojiPicker = false; + this.showCommentEmojiPicker = !this.showCommentEmojiPicker; + } + + detectSystemTheme() { + const darkSchemeMedia = window.matchMedia( + '(prefers-color-scheme: dark)' + ); + this.darkMode = darkSchemeMedia.matches; + + darkSchemeMedia.addEventListener('change', (event) => { + this.darkMode = event.matches; + }); + } + + openConfirmationDialog(): void { + const dialogRef = this._angorConfirmationService.open({ + title: 'Share Event', + message: + 'Are you sure you want to share this event on your profile? This action is permanent and cannot be undone.', + icon: { + show: true, + name: 'heroicons_solid:share', + color: 'primary', + }, + actions: { + confirm: { + show: true, + label: 'Yes, Share', + color: 'primary', + }, + cancel: { + show: true, + label: 'Cancel', + }, + }, + dismissible: true, + }); + + dialogRef.afterClosed().subscribe((result) => { + console.log(result); + }); + } + + togglePreview() { + this.isPreview = !this.isPreview; + } + + sendEvent() { + if (this.eventInput.nativeElement.value != '') { + this.eventService + .sendTextEvent(this.eventInput.nativeElement.value) + .then(() => { + this._changeDetectorRef.markForCheck(); + }) + .catch((error) => { + console.error('Failed to send Event:', error); + }); + } + } } diff --git a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.html b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.html index d42b5047..30b0f007 100644 --- a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.html +++ b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.html @@ -1,7 +1,20 @@ +
+ +

โšก Receive Zap

- @@ -9,12 +22,14 @@

โšก Receive Zap

Zap Amount - + - @@ -25,14 +40,22 @@

โšก Receive Zap

Scan with phone to pay ({{ invoiceAmount }} sats) - + - -
diff --git a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.scss b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.scss index 5a9fdfbf..c10d21e5 100644 --- a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.scss +++ b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.scss @@ -4,9 +4,9 @@ gap: 15px; justify-items: center; margin-bottom: 20px; - } +} - .preset-buttons button { +.preset-buttons button { font-size: 14px; font-weight: bold; width: 70px; @@ -16,24 +16,23 @@ align-items: center; justify-content: center; max-height: 60px !important; - } +} - .sats-input { +.sats-input { margin-top: 20px; width: 100%; - } +} - .lightning-buttons { +.lightning-buttons { display: flex; justify-content: space-evenly; margin: 10px 0; - } +} - - .qrcode { +.qrcode { text-align: center; - } +} - .qrcode-image { +.qrcode-image { width: 100% !important; - } +} diff --git a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.ts b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.ts index 4102050d..3dd22405 100644 --- a/src/app/components/profile/zap/receive-dialog/receive-dialog.component.ts +++ b/src/app/components/profile/zap/receive-dialog/receive-dialog.component.ts @@ -1,19 +1,28 @@ -import { Component } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { Clipboard } from '@angular/cdk/clipboard'; -import { MatDialogActions, MatDialogContent, MatDialogRef } from '@angular/material/dialog'; -import { webln } from '@getalby/sdk'; -import { NgClass, CommonModule } from '@angular/common'; +import { CommonModule, NgClass } from '@angular/common'; +import { Component } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; +import { + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogRef, +} from '@angular/material/dialog'; import { MatDivider } from '@angular/material/divider'; -import { MatLabel, MatFormField, MatFormFieldModule } from '@angular/material/form-field'; +import { + MatFormField, + MatFormFieldModule, + MatLabel, +} from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; +import { webln } from '@getalby/sdk'; import { QRCodeModule } from 'angularx-qrcode'; import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component'; import { SettingsNetworkComponent } from 'app/components/settings/network/network.component'; @@ -23,112 +32,116 @@ import { SettingsRelayComponent } from 'app/components/settings/relay/relay.comp import { SettingsSecurityComponent } from 'app/components/settings/security/security.component'; @Component({ - selector: 'app-receive-dialog', - standalone: true, - imports: [ - MatSidenavModule, - MatButtonModule, - MatIconModule, - NgClass, - SettingsProfileComponent, - SettingsSecurityComponent, - SettingsNotificationsComponent, - SettingsRelayComponent, - SettingsNetworkComponent, - SettingsIndexerComponent, - FormsModule, - MatOption, - MatLabel, - MatFormField, - ReactiveFormsModule, - CommonModule, - MatSelectModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatDialogContent, - MatDialogActions, - QRCodeModule, - MatDivider, - MatTooltip - ], - templateUrl: './receive-dialog.component.html', - styleUrls: ['./receive-dialog.component.scss'] + selector: 'app-receive-dialog', + standalone: true, + imports: [ + MatSidenavModule, + MatButtonModule, + MatIconModule, + NgClass, + SettingsProfileComponent, + SettingsSecurityComponent, + SettingsNotificationsComponent, + SettingsRelayComponent, + SettingsNetworkComponent, + SettingsIndexerComponent, + FormsModule, + MatOption, + MatLabel, + MatFormField, + ReactiveFormsModule, + CommonModule, + MatSelectModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatDialogContent, + MatDialogActions, + QRCodeModule, + MatDivider, + MatTooltip, + MatDialogClose, + ], + templateUrl: './receive-dialog.component.html', + styleUrls: ['./receive-dialog.component.scss'], }) export class ReceiveDialogComponent { - invoiceAmount: string = ''; - lightningInvoice: string = ''; - displayQRCode: boolean = false; - nwc: any; + invoiceAmount: string = ''; + lightningInvoice: string = ''; + displayQRCode: boolean = false; + nwc: any; - constructor( - private dialogRef: MatDialogRef, - private snackBar: MatSnackBar, - private clipboard: Clipboard - ) {} + constructor( + private dialogRef: MatDialogRef, + private snackBar: MatSnackBar, + private clipboard: Clipboard + ) {} - zapButtons = [ - { icon: 'thumb_up', label: '50', value: 50 }, - { icon: 'favorite', label: '100', value: 100 }, - { icon: 'emoji_emotions', label: '500', value: 500 }, - { icon: 'star', label: '1k', value: 1000 }, - { icon: 'celebration', label: '5k', value: 5000 }, - { icon: 'rocket', label: '10k', value: 10000 }, - { icon: 'local_fire_department', label: '100k', value: 100000 }, - { icon: 'flash_on', label: '500k', value: 500000 }, - { icon: 'diamond', label: '1M', value: 1000000 } -]; + zapButtons = [ + { icon: 'thumb_up', label: '50', value: 50 }, + { icon: 'favorite', label: '100', value: 100 }, + { icon: 'emoji_emotions', label: '500', value: 500 }, + { icon: 'star', label: '1k', value: 1000 }, + { icon: 'celebration', label: '5k', value: 5000 }, + { icon: 'rocket', label: '10k', value: 10000 }, + { icon: 'local_fire_department', label: '100k', value: 100000 }, + { icon: 'flash_on', label: '500k', value: 500000 }, + { icon: 'diamond', label: '1M', value: 1000000 }, + ]; -async generateInvoice(): Promise { - if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) { - this.openSnackBar('Please enter a valid amount', 'dismiss'); - return; - } - - try { + async generateInvoice(): Promise { + if (!this.invoiceAmount || Number(this.invoiceAmount) <= 0) { + this.openSnackBar('Please enter a valid amount', 'dismiss'); + return; + } - this.nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: await this.loadNWCUrl() }); - await this.nwc.enable(); + try { + this.nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: await this.loadNWCUrl(), + }); + await this.nwc.enable(); - const invoiceResponse = await this.nwc.makeInvoice({ amount: Number(this.invoiceAmount) }); - this.lightningInvoice = invoiceResponse.paymentRequest; + const invoiceResponse = await this.nwc.makeInvoice({ + amount: Number(this.invoiceAmount), + }); + this.lightningInvoice = invoiceResponse.paymentRequest; - this.showQRCode(); - } catch (error) { - console.error('Error generating invoice:', error); - this.openSnackBar('Failed to generate invoice', 'dismiss'); + this.showQRCode(); + } catch (error) { + console.error('Error generating invoice:', error); + this.openSnackBar('Failed to generate invoice', 'dismiss'); + } } - } - async loadNWCUrl(): Promise { - try { - const nwc = webln.NostrWebLNProvider.withNewSecret(); - await nwc.initNWC({ name: 'Angor Hub' }); - return nwc.getNostrWalletConnectUrl(); - } catch (error) { - console.error('Error initializing NWC:', error); - throw new Error('Failed to initialize NWC provider'); + async loadNWCUrl(): Promise { + try { + const nwc = webln.NostrWebLNProvider.withNewSecret(); + await nwc.initNWC({ name: 'Angor Hub' }); + return nwc.getNostrWalletConnectUrl(); + } catch (error) { + console.error('Error initializing NWC:', error); + throw new Error('Failed to initialize NWC provider'); + } } - } - showQRCode(): void { - this.displayQRCode = !this.displayQRCode; - } + showQRCode(): void { + this.displayQRCode = !this.displayQRCode; + } - copyInvoice(): void { - if (this.lightningInvoice) { - this.clipboard.copy(this.lightningInvoice); - this.openSnackBar('Invoice copied', 'dismiss'); - } else { - this.openSnackBar('No invoice available to copy', 'dismiss'); + copyInvoice(): void { + if (this.lightningInvoice) { + this.clipboard.copy(this.lightningInvoice); + this.openSnackBar('Invoice copied', 'dismiss'); + } else { + this.openSnackBar('No invoice available to copy', 'dismiss'); + } } - } - openSnackBar(message: string, action: string): void { - this.snackBar.open(message, action, { duration: 1300 }); - } + openSnackBar(message: string, action: string): void { + this.snackBar.open(message, action, { duration: 1300 }); + } - closeDialog(): void { - this.dialogRef.close(); - } + closeDialog(): void { + this.dialogRef.close(); + } } diff --git a/src/app/components/profile/zap/send-dialog/send-dialog.component.html b/src/app/components/profile/zap/send-dialog/send-dialog.component.html index ff37667f..eb2c9260 100644 --- a/src/app/components/profile/zap/send-dialog/send-dialog.component.html +++ b/src/app/components/profile/zap/send-dialog/send-dialog.component.html @@ -1,7 +1,20 @@ +
+ +

โšก Send Zap

- @@ -9,13 +22,15 @@

โšก Send Zap

Zap Amount - + - @@ -26,20 +41,30 @@

โšก Send Zap

Scan with phone to pay ({{ invoiceAmount }} sats) - +
- - - - diff --git a/src/app/components/profile/zap/send-dialog/send-dialog.component.scss b/src/app/components/profile/zap/send-dialog/send-dialog.component.scss index 5a9fdfbf..c10d21e5 100644 --- a/src/app/components/profile/zap/send-dialog/send-dialog.component.scss +++ b/src/app/components/profile/zap/send-dialog/send-dialog.component.scss @@ -4,9 +4,9 @@ gap: 15px; justify-items: center; margin-bottom: 20px; - } +} - .preset-buttons button { +.preset-buttons button { font-size: 14px; font-weight: bold; width: 70px; @@ -16,24 +16,23 @@ align-items: center; justify-content: center; max-height: 60px !important; - } +} - .sats-input { +.sats-input { margin-top: 20px; width: 100%; - } +} - .lightning-buttons { +.lightning-buttons { display: flex; justify-content: space-evenly; margin: 10px 0; - } +} - - .qrcode { +.qrcode { text-align: center; - } +} - .qrcode-image { +.qrcode-image { width: 100% !important; - } +} diff --git a/src/app/components/profile/zap/send-dialog/send-dialog.component.ts b/src/app/components/profile/zap/send-dialog/send-dialog.component.ts index 24a8f5e2..b38c69a2 100644 --- a/src/app/components/profile/zap/send-dialog/send-dialog.component.ts +++ b/src/app/components/profile/zap/send-dialog/send-dialog.component.ts @@ -1,29 +1,40 @@ -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogActions, MatDialogContent, MatDialogRef, MatDialogTitle } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { LightningService } from 'app/services/lightning.service'; import { Clipboard } from '@angular/cdk/clipboard'; -import { webln } from '@getalby/sdk'; -import { decode } from '@gandlaf21/bolt11-decode'; -import { bech32 } from '@scure/base'; -import { NgClass, CommonModule } from '@angular/common'; +import { CommonModule, NgClass } from '@angular/common'; +import { Component, Inject } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; -import { MatLabel, MatFormField, MatFormFieldModule } from '@angular/material/form-field'; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogRef, + MatDialogTitle, +} from '@angular/material/dialog'; +import { MatDivider } from '@angular/material/divider'; +import { + MatFormField, + MatFormFieldModule, + MatLabel, +} from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { decode } from '@gandlaf21/bolt11-decode'; +import { webln } from '@getalby/sdk'; +import { bech32 } from '@scure/base'; +import { QRCodeModule } from 'angularx-qrcode'; import { SettingsIndexerComponent } from 'app/components/settings/indexer/indexer.component'; import { SettingsNetworkComponent } from 'app/components/settings/network/network.component'; import { SettingsNotificationsComponent } from 'app/components/settings/notifications/notifications.component'; import { SettingsProfileComponent } from 'app/components/settings/profile/profile.component'; import { SettingsRelayComponent } from 'app/components/settings/relay/relay.component'; import { SettingsSecurityComponent } from 'app/components/settings/security/security.component'; -import { QRCodeModule } from 'angularx-qrcode'; -import { MatDivider } from '@angular/material/divider'; -import { MatTooltip } from '@angular/material/tooltip'; +import { LightningService } from 'app/services/lightning.service'; @Component({ selector: 'app-send-dialog', @@ -54,10 +65,11 @@ import { MatTooltip } from '@angular/material/tooltip'; QRCodeModule, MatDivider, MatTooltip, - MatDialogTitle + MatDialogTitle, + MatDialogClose, ], templateUrl: './send-dialog.component.html', - styleUrls: ['./send-dialog.component.scss'] + styleUrls: ['./send-dialog.component.scss'], }) export class SendDialogComponent { sats: string; @@ -66,7 +78,7 @@ export class SendDialogComponent { showInvoiceSection: boolean = false; displayQRCode: boolean = false; invoiceAmount: string = '?'; - nwc :any; + nwc: any; constructor( private dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public metadata: any, @@ -86,7 +98,7 @@ export class SendDialogComponent { { icon: 'rocket', label: '10k', value: 10000 }, { icon: 'local_fire_department', label: '100k', value: 100000 }, { icon: 'flash_on', label: '500k', value: 500000 }, - { icon: 'diamond', label: '1M', value: 1000000 } + { icon: 'diamond', label: '1M', value: 1000000 }, ]; getLightningInfo(): void { @@ -100,20 +112,30 @@ export class SendDialogComponent { const data = new Uint8Array(bech32.fromWords(words)); lightningAddress = new TextDecoder().decode(Uint8Array.from(data)); } else if (this.metadata?.lud16) { - lightningAddress = this.lightning.getLightningAddress(this.metadata.lud16); + lightningAddress = this.lightning.getLightningAddress( + this.metadata.lud16 + ); } if (lightningAddress !== '') { - this.lightning.getLightning(lightningAddress).subscribe((response) => { - this.lightningResponse = response; - if (this.lightningResponse.status === 'Failed') { - this.openSnackBar('Failed to lookup lightning address', 'dismiss'); - } else if (this.lightningResponse.callback) { - this.showInvoiceSection = true; - } else { - this.openSnackBar("Couldn't find user's lightning address", 'dismiss'); - } - }); + this.lightning + .getLightning(lightningAddress) + .subscribe((response) => { + this.lightningResponse = response; + if (this.lightningResponse.status === 'Failed') { + this.openSnackBar( + 'Failed to lookup lightning address', + 'dismiss' + ); + } else if (this.lightningResponse.callback) { + this.showInvoiceSection = true; + } else { + this.openSnackBar( + "Couldn't find user's lightning address", + 'dismiss' + ); + } + }); } else { this.openSnackBar('No lightning address found', 'dismiss'); } @@ -121,8 +143,9 @@ export class SendDialogComponent { getLightningInvoice(amount: string): void { if (this.lightningResponse && this.lightningResponse.callback) { - this.lightning.getLightningInvoice(this.lightningResponse.callback, amount) - .subscribe(async response => { + this.lightning + .getLightningInvoice(this.lightningResponse.callback, amount) + .subscribe(async (response) => { this.lightningInvoice = response.pr; this.setInvoiceAmount(this.lightningInvoice); this.showInvoiceSection = true; @@ -134,7 +157,9 @@ export class SendDialogComponent { setInvoiceAmount(invoice: string): void { if (invoice) { const decodedInvoice = decode(invoice); - const amountSection = decodedInvoice.sections.find((s) => s.name === 'amount'); + const amountSection = decodedInvoice.sections.find( + (s) => s.name === 'amount' + ); if (amountSection) { this.invoiceAmount = String(Number(amountSection.value) / 1000); } @@ -149,29 +174,32 @@ export class SendDialogComponent { this.getLightningInvoice(String(Number(this.sats) * 1000)); } - async payInvoice(): Promise { if (!this.lightningInvoice) { console.error('Lightning invoice is not set'); return; } - const nwc = new webln.NostrWebLNProvider({ nostrWalletConnectUrl: await this.loadNWCUrl() }); + const nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: await this.loadNWCUrl(), + }); nwc.enable() .then(() => { return nwc.sendPayment(this.lightningInvoice); }) - .then(response => { + .then((response) => { if (response && response.preimage) { - console.log(`Payment successful, preimage: ${response.preimage}`); + console.log( + `Payment successful, preimage: ${response.preimage}` + ); this.openSnackBar('Zapped!', 'dismiss'); this.dialogRef.close(); } else { this.listenForPaymentStatus(nwc); } }) - .catch(error => { + .catch((error) => { console.error('Payment failed:', error); this.openSnackBar('Failed to pay invoice', 'dismiss'); this.listenForPaymentStatus(nwc); @@ -179,13 +207,14 @@ export class SendDialogComponent { } loadNWCUrl(): Promise { - const nwc = webln.NostrWebLNProvider.withNewSecret(); + const nwc = webln.NostrWebLNProvider.withNewSecret(); - return nwc.initNWC({ name: 'Angor Hub' }) + return nwc + .initNWC({ name: 'Angor Hub' }) .then(() => { return nwc.getNostrWalletConnectUrl(); }) - .catch(error => { + .catch((error) => { console.error('Error initializing NWC:', error); throw error; }); @@ -194,16 +223,19 @@ export class SendDialogComponent { listenForPaymentStatus(nwc): void { const checkPaymentStatus = () => { nwc.sendPayment(this.lightningInvoice) - .then(response => { + .then((response) => { if (response && response.preimage) { - console.log('Payment confirmed, preimage:', response.preimage); + console.log( + 'Payment confirmed, preimage:', + response.preimage + ); this.openSnackBar('Payment confirmed!', 'dismiss'); this.dialogRef.close(); } else { setTimeout(checkPaymentStatus, 5000); } }) - .catch(error => { + .catch((error) => { console.error('Error checking payment status:', error); setTimeout(checkPaymentStatus, 5000); }); @@ -228,5 +260,4 @@ export class SendDialogComponent { closeDialog(): void { this.dialogRef.close(); } - } diff --git a/src/app/components/settings/indexer/indexer.component.html b/src/app/components/settings/indexer/indexer.component.html index ee0c2937..7f6bfbed 100644 --- a/src/app/components/settings/indexer/indexer.component.html +++ b/src/app/components/settings/indexer/indexer.component.html @@ -1,12 +1,23 @@
-
+
Add Mainnet Indexer - - + +
@@ -15,22 +26,43 @@

Mainnet Indexers

-
+
{{ indexer.url }}
-
Primary: {{ indexer.primary ? 'Yes' : 'No' }}
+
+ Primary: {{ indexer.primary ? 'Yes' : 'No' }} +
- -
@@ -38,13 +70,24 @@

Mainnet Indexers

-
+
Add Testnet Indexer - - + +
@@ -53,22 +96,43 @@

Mainnet Indexers

Testnet Indexers

-
+
{{ indexer.url }}
-
Primary: {{ indexer.primary ? 'Yes' : 'No' }}
+
+ Primary: {{ indexer.primary ? 'Yes' : 'No' }} +
- -
diff --git a/src/app/components/settings/indexer/indexer.component.ts b/src/app/components/settings/indexer/indexer.component.ts index 78704895..82638c72 100644 --- a/src/app/components/settings/indexer/indexer.component.ts +++ b/src/app/components/settings/indexer/indexer.component.ts @@ -1,3 +1,4 @@ +import { AngorAlertComponent } from '@angor/components/alert'; import { CommonModule, CurrencyPipe, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, @@ -5,12 +6,7 @@ import { OnInit, ViewEncapsulation, } from '@angular/core'; -import { - FormsModule, - ReactiveFormsModule, - UntypedFormBuilder, - UntypedFormGroup, -} from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -18,7 +14,6 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; -import { AngorAlertComponent } from '@angor/components/alert'; import { IndexerService } from 'app/services/indexer.service'; @Component({ @@ -40,59 +35,76 @@ import { IndexerService } from 'app/services/indexer.service'; MatOptionModule, MatButtonModule, CurrencyPipe, - CommonModule + CommonModule, ], }) export class SettingsIndexerComponent implements OnInit { - mainnetIndexers: Array<{ url: string, primary: boolean }> = []; - testnetIndexers: Array<{ url: string, primary: boolean }> = []; - newMainnetIndexerUrl: string = ''; - newTestnetIndexerUrl: string = ''; + mainnetIndexers: Array<{ url: string; primary: boolean }> = []; + testnetIndexers: Array<{ url: string; primary: boolean }> = []; + newMainnetIndexerUrl: string = ''; + newTestnetIndexerUrl: string = ''; - constructor(private indexerService: IndexerService) {} + constructor(private indexerService: IndexerService) {} - ngOnInit(): void { - this.loadIndexers(); - } - - loadIndexers(): void { - this.mainnetIndexers = this.indexerService.getIndexers('mainnet').map(url => ({ - url, - primary: url === this.indexerService.getPrimaryIndexer('mainnet') - })); - this.testnetIndexers = this.indexerService.getIndexers('testnet').map(url => ({ - url, - primary: url === this.indexerService.getPrimaryIndexer('testnet') - })); + ngOnInit(): void { + this.loadIndexers(); + } - console.log('Mainnet Indexers:', this.mainnetIndexers); - console.log('Testnet Indexers:', this.testnetIndexers); - } + loadIndexers(): void { + this.mainnetIndexers = this.indexerService + .getIndexers('mainnet') + .map((url) => ({ + url, + primary: + url === this.indexerService.getPrimaryIndexer('mainnet'), + })); + this.testnetIndexers = this.indexerService + .getIndexers('testnet') + .map((url) => ({ + url, + primary: + url === this.indexerService.getPrimaryIndexer('testnet'), + })); + console.log('Mainnet Indexers:', this.mainnetIndexers); + console.log('Testnet Indexers:', this.testnetIndexers); + } - addIndexer(network: 'mainnet' | 'testnet'): void { - if (network === 'mainnet' && this.newMainnetIndexerUrl) { - this.indexerService.addIndexer(this.newMainnetIndexerUrl, 'mainnet'); - this.loadIndexers(); - this.newMainnetIndexerUrl = ''; - } else if (network === 'testnet' && this.newTestnetIndexerUrl) { - this.indexerService.addIndexer(this.newTestnetIndexerUrl, 'testnet'); - this.loadIndexers(); - this.newTestnetIndexerUrl = ''; + addIndexer(network: 'mainnet' | 'testnet'): void { + if (network === 'mainnet' && this.newMainnetIndexerUrl) { + this.indexerService.addIndexer( + this.newMainnetIndexerUrl, + 'mainnet' + ); + this.loadIndexers(); + this.newMainnetIndexerUrl = ''; + } else if (network === 'testnet' && this.newTestnetIndexerUrl) { + this.indexerService.addIndexer( + this.newTestnetIndexerUrl, + 'testnet' + ); + this.loadIndexers(); + this.newTestnetIndexerUrl = ''; + } } - } - removeIndexer(network: 'mainnet' | 'testnet', indexer: { url: string, primary: boolean }): void { - this.indexerService.removeIndexer(indexer.url, network); - this.loadIndexers(); - } + removeIndexer( + network: 'mainnet' | 'testnet', + indexer: { url: string; primary: boolean } + ): void { + this.indexerService.removeIndexer(indexer.url, network); + this.loadIndexers(); + } - setPrimaryIndexer(network: 'mainnet' | 'testnet', indexer: { url: string, primary: boolean }): void { - this.indexerService.setPrimaryIndexer(indexer.url, network); - this.loadIndexers(); - } + setPrimaryIndexer( + network: 'mainnet' | 'testnet', + indexer: { url: string; primary: boolean } + ): void { + this.indexerService.setPrimaryIndexer(indexer.url, network); + this.loadIndexers(); + } - trackByFn(index: number, item: any): any { - return item.url; - } + trackByFn(index: number, item: any): any { + return item.url; + } } diff --git a/src/app/components/settings/network/network.component.html b/src/app/components/settings/network/network.component.html index 08cef753..c660ae40 100644 --- a/src/app/components/settings/network/network.component.html +++ b/src/app/components/settings/network/network.component.html @@ -46,11 +46,18 @@
-
+
-
+
- +
- diff --git a/src/app/components/settings/network/network.component.ts b/src/app/components/settings/network/network.component.ts index 9a4b51f2..d9b48c21 100644 --- a/src/app/components/settings/network/network.component.ts +++ b/src/app/components/settings/network/network.component.ts @@ -1,7 +1,12 @@ import { AngorAlertComponent } from '@angor/components/alert'; -import { NgClass, CurrencyPipe, CommonModule } from '@angular/common'; +import { CommonModule, CurrencyPipe, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -12,49 +17,52 @@ import { MatSelectModule } from '@angular/material/select'; import { IndexerService } from 'app/services/indexer.service'; @Component({ - selector: 'settings-network', - templateUrl: './network.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, - standalone: true, - imports: [ - FormsModule, - ReactiveFormsModule, - AngorAlertComponent, - MatRadioModule, - NgClass, - MatIconModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatOptionModule, - MatButtonModule, - CurrencyPipe, - CommonModule - ], + selector: 'settings-network', + templateUrl: './network.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + FormsModule, + ReactiveFormsModule, + AngorAlertComponent, + MatRadioModule, + NgClass, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatOptionModule, + MatButtonModule, + CurrencyPipe, + CommonModule, + ], }) export class SettingsNetworkComponent implements OnInit { networkForm: FormGroup; selectedNetwork: 'mainnet' | 'testnet' = 'testnet'; - constructor(private fb: FormBuilder, private indexerService: IndexerService) {} + constructor( + private fb: FormBuilder, + private indexerService: IndexerService + ) {} ngOnInit(): void { - this.networkForm = this.fb.group({ - network: [this.indexerService.getNetwork()] - }); + this.networkForm = this.fb.group({ + network: [this.indexerService.getNetwork()], + }); - this.selectedNetwork = this.indexerService.getNetwork(); + this.selectedNetwork = this.indexerService.getNetwork(); } setNetwork(network: 'mainnet' | 'testnet'): void { - this.selectedNetwork = network; - this.indexerService.setNetwork(this.selectedNetwork); + this.selectedNetwork = network; + this.indexerService.setNetwork(this.selectedNetwork); } save(): void { - this.indexerService.setNetwork(this.selectedNetwork); - } + this.indexerService.setNetwork(this.selectedNetwork); + } cancel(): void { - this.selectedNetwork = this.indexerService.getNetwork(); + this.selectedNetwork = this.indexerService.getNetwork(); } } diff --git a/src/app/components/settings/notifications/notifications.component.html b/src/app/components/settings/notifications/notifications.component.html index 6827fdb4..2fa186a6 100644 --- a/src/app/components/settings/notifications/notifications.component.html +++ b/src/app/components/settings/notifications/notifications.component.html @@ -5,38 +5,78 @@
-
+
Mention
-
Receive notifications when someone mentions you.
+
+ Receive notifications when someone mentions you. +
- +
-
+
Private Message
-
Receive notifications for private messages.
+
+ Receive notifications for private messages. +
- +
-
+
Zap
-
Receive notifications when you get a zap.
+
+ Receive notifications when you get a zap. +
- +
-
+
New Follower
-
Receive notifications when someone follows you.
+
+ Receive notifications when someone follows you. +
- +
@@ -45,7 +85,15 @@
- +
diff --git a/src/app/components/settings/notifications/notifications.component.ts b/src/app/components/settings/notifications/notifications.component.ts index a9cddbef..cc88eb72 100644 --- a/src/app/components/settings/notifications/notifications.component.ts +++ b/src/app/components/settings/notifications/notifications.component.ts @@ -42,7 +42,9 @@ export class SettingsNotificationsComponent implements OnInit { this.notificationsForm = this._formBuilder.group({ mention: [savedSettings.includes(this.notificationKinds.mention)], - privateMessage: [savedSettings.includes(this.notificationKinds.privateMessage)], + privateMessage: [ + savedSettings.includes(this.notificationKinds.privateMessage), + ], zap: [savedSettings.includes(this.notificationKinds.zap)], follower: [savedSettings.includes(this.notificationKinds.follower)], }); @@ -75,6 +77,6 @@ export class SettingsNotificationsComponent implements OnInit { private loadNotificationSettings(): number[] { const storedSettings = localStorage.getItem('notificationSettings'); - return storedSettings ? JSON.parse(storedSettings) : [1, 3, 4, 9735]; // Default to all kinds if not set + return storedSettings ? JSON.parse(storedSettings) : [1, 3, 4, 7, 9735]; // Default to all kinds if not set } } diff --git a/src/app/components/settings/profile/profile.component.html b/src/app/components/settings/profile/profile.component.html index db3a2ec5..d9fdd561 100644 --- a/src/app/components/settings/profile/profile.component.html +++ b/src/app/components/settings/profile/profile.component.html @@ -3,7 +3,7 @@
-
+
Following information is publicly displayed, be careful!
@@ -13,7 +13,11 @@
Name - +
@@ -46,10 +50,16 @@
About - +
- Brief description for your profile. Basic HTML and Emoji are allowed. + Brief description for your profile. Basic HTML and Emoji are + allowed.
@@ -69,45 +79,51 @@
- -
- - LUD06 - - - LUD06 is an LNURL (Lightning Network URL) for receiving Bitcoin payments over the Lightning Network. - - -
- - -
- - LUD16 - - - LUD16 is a Lightning address, similar to an email format, used to receive Bitcoin payments via the Lightning Network. - - -
+ +
+ + LUD06 + + + LUD06 is an LNURL (Lightning Network URL) for receiving + Bitcoin payments over the Lightning Network. + + +
- -
- - NIP05 - - - NIP05 provides a user-friendly identifier for Nostr, similar to an email address, to help identify and verify your public identity. - - -
+ +
+ + LUD16 + + + LUD16 is a Lightning address, similar to an email + format, used to receive Bitcoin payments via the + Lightning Network. + + +
+ +
+ + NIP05 + + + NIP05 provides a user-friendly identifier for Nostr, + similar to an email address, to help identify and verify + your public identity. + + +
-
+
- +
diff --git a/src/app/components/settings/profile/profile.component.ts b/src/app/components/settings/profile/profile.component.ts index 608e36b9..c85b17e6 100644 --- a/src/app/components/settings/profile/profile.component.ts +++ b/src/app/components/settings/profile/profile.component.ts @@ -1,10 +1,21 @@ -import { MatDialog } from '@angular/material/dialog'; import { TextFieldModule } from '@angular/cdk/text-field'; import { CommonModule } from '@angular/common'; -import { Component, ViewEncapsulation, ChangeDetectionStrategy, OnInit } from '@angular/core'; -import { FormsModule, ReactiveFormsModule, FormGroup, FormBuilder, Validators } from '@angular/forms'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + ViewEncapsulation, +} from '@angular/core'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOptionModule } from '@angular/material/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; @@ -14,9 +25,9 @@ import { hexToBytes } from '@noble/hashes/utils'; import { MetadataService } from 'app/services/metadata.service'; import { RelayService } from 'app/services/relay.service'; import { SignerService } from 'app/services/signer.service'; -import { UnsignedEvent, NostrEvent, finalizeEvent } from 'nostr-tools'; import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dialog.component'; - +import { NostrEvent, UnsignedEvent, finalizeEvent } from 'nostr-tools'; + @Component({ selector: 'settings-profile', templateUrl: './profile.component.html', @@ -33,7 +44,7 @@ import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dia MatSelectModule, MatOptionModule, MatButtonModule, - CommonModule + CommonModule, ], }) export class SettingsProfileComponent implements OnInit { @@ -46,8 +57,8 @@ export class SettingsProfileComponent implements OnInit { private metadataService: MetadataService, private relayService: RelayService, private router: Router, - private dialog: MatDialog, - ) { } + private dialog: MatDialog + ) {} ngOnInit(): void { this.profileForm = this.fb.group({ @@ -59,15 +70,23 @@ export class SettingsProfileComponent implements OnInit { picture: [''], banner: [''], lud06: [''], - lud16: ['', Validators.pattern("^[a-z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,4}$")], - nip05: ['', Validators.pattern("^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,4}$")] + lud16: [ + '', + Validators.pattern('^[a-z0-9._-]+@[a-z0-9.-]+.[a-z]{2,4}$'), + ], + nip05: [ + '', + Validators.pattern('^[a-z0-9._%+-]+@[a-z0-9.-]+.[a-z]{2,4}$'), + ], }); this.setValues(); } async setValues() { - let kind0 = await this.metadataService.getUserMetadata(this.signerService.getPublicKey()); + let kind0 = await this.metadataService.getUserMetadata( + this.signerService.getPublicKey() + ); if (kind0) { this.profileForm.setValue({ name: kind0.name || '', @@ -100,7 +119,8 @@ export class SettingsProfileComponent implements OnInit { const storedPassword = this.signerService.getPassword(); if (storedPassword) { try { - const privateKey = await this.signerService.getSecretKey(storedPassword); + const privateKey = + await this.signerService.getSecretKey(storedPassword); this.signEvent(privateKey); } catch (error) { console.error(error); @@ -108,46 +128,54 @@ export class SettingsProfileComponent implements OnInit { } else { const dialogRef = this.dialog.open(PasswordDialogComponent, { width: '300px', - disableClose: true + disableClose: true, }); - dialogRef.afterClosed().subscribe(async result => { + dialogRef.afterClosed().subscribe(async (result) => { if (result && result.password) { try { - const privateKey = await this.signerService.getSecretKey(result.password); + const privateKey = + await this.signerService.getSecretKey( + result.password + ); this.signEvent(privateKey); if (result.duration != 0) { - this.signerService.savePassword(result.password, result.duration); + this.signerService.savePassword( + result.password, + result.duration + ); } } catch (error) { console.error(error); - } - } else { console.error('Password not provided'); } }); } - - } else if (this.signerService.isUsingExtension()) { - const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(0, [], this.content); - const signedEvent = await this.signerService.signEventWithExtension(unsignedEvent); + const unsignedEvent: UnsignedEvent = + this.signerService.getUnsignedEvent(0, [], this.content); + const signedEvent = + await this.signerService.signEventWithExtension(unsignedEvent); this.publishSignedEvent(signedEvent); } } async signEvent(privateKey: string) { - const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(0, [], this.content); + const unsignedEvent: UnsignedEvent = + this.signerService.getUnsignedEvent(0, [], this.content); const privateKeyBytes = hexToBytes(privateKey); - const signedEvent: NostrEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + const signedEvent: NostrEvent = finalizeEvent( + unsignedEvent, + privateKeyBytes + ); this.publishSignedEvent(signedEvent); } publishSignedEvent(signedEvent: NostrEvent) { this.relayService.publishEventToRelays(signedEvent); - console.log("Profile Updated!"); + console.log('Profile Updated!'); this.router.navigate([`/profile`]); } } diff --git a/src/app/components/settings/relay/relay.component.html b/src/app/components/settings/relay/relay.component.html index 8f4b00f7..cd5116e3 100644 --- a/src/app/components/settings/relay/relay.component.html +++ b/src/app/components/settings/relay/relay.component.html @@ -3,42 +3,72 @@
Add Relay - +
-
+
-
- relay avatar - +
+ relay avatar
{{ relay.url }}
-
Status: {{ getRelayStatus(relay) }} +
+ Status: {{ getRelayStatus(relay) }}
- - + + - {{ relay.accessType | titlecase }} + {{ + relay.accessType | titlecase + }} - +
{{ option.label }}
diff --git a/src/app/components/settings/relay/relay.component.ts b/src/app/components/settings/relay/relay.component.ts index 45acc34c..e81e548b 100644 --- a/src/app/components/settings/relay/relay.component.ts +++ b/src/app/components/settings/relay/relay.component.ts @@ -1,11 +1,11 @@ import { CommonModule, TitleCasePipe } from '@angular/common'; import { ChangeDetectionStrategy, + ChangeDetectorRef, Component, + NgZone, OnInit, ViewEncapsulation, - ChangeDetectorRef, - NgZone, } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; @@ -52,7 +52,7 @@ export class SettingsRelayComponent implements OnInit { ngOnInit(): void { // Subscribe to relays observable this.subscriptions.add( - this.relayService.getRelays().subscribe(relays => { + this.relayService.getRelays().subscribe((relays) => { this.zone.run(() => { this.relays = relays; this.cdr.markForCheck(); // Mark the component for check @@ -65,17 +65,20 @@ export class SettingsRelayComponent implements OnInit { { label: 'Read', value: 'read', - description: 'Reads only, does not write, unless explicitly specified on publish action.', + description: + 'Reads only, does not write, unless explicitly specified on publish action.', }, { label: 'Write', value: 'write', - description: 'Writes your events, profile, and other metadata updates. Connects on-demand.', + description: + 'Writes your events, profile, and other metadata updates. Connects on-demand.', }, { label: 'Read and Write', value: 'read-write', - description: 'Reads and writes events, profiles, and other metadata. Always connected.', + description: + 'Reads and writes events, profiles, and other metadata. Always connected.', }, ]; } @@ -113,13 +116,14 @@ export class SettingsRelayComponent implements OnInit { } relayFavIcon(url: string): string { - let safeUrl = url.replace('wss://', 'https://').replace('ws://', 'https://'); + let safeUrl = url + .replace('wss://', 'https://') + .replace('ws://', 'https://'); return safeUrl + '/favicon.ico'; - } + } - getSafeUrl(url: string): SafeUrl { + getSafeUrl(url: string): SafeUrl { return this.sanitizer.bypassSecurityTrustUrl(url); - } - + } } diff --git a/src/app/components/settings/security/security.component.html b/src/app/components/settings/security/security.component.html index 5bddedf2..76a128e5 100644 --- a/src/app/components/settings/security/security.component.html +++ b/src/app/components/settings/security/security.component.html @@ -42,9 +42,7 @@ required /> -
- Minimum 3 characters. -
+
Minimum 3 characters.
@@ -66,10 +64,12 @@ (click)="savePasswordToggle.toggle()" >
- Save password for future decrypting sensitive information. + Save password for future decrypting sensitive + information.
- Enable this option to save your password locally for 60 minutes. + Enable this option to save your password locally for 60 + minutes.
+
- +
-
+
Settings
@for (panel of panels; track trackByFn($index, panel)) { -
- + -
-
+
+
- {{ panel.title }} -
-
- {{ panel.description }} + }" + > + {{ panel.title }} +
+
+ {{ panel.description }} +
-
}
@@ -56,12 +77,20 @@
- -
+
{{ getPanelInfo(selectedPanel).title }}
@@ -69,31 +98,30 @@
@switch (selectedPanel) { - - @case ('relay') { - - } - - @case ('network') { - - } - - @case ('indexer') { - - } - - @case ('profile') { - - } - - @case ('security') { - - } - - @case ('notifications') { - - } - + + @case ('relay') { + + } + + @case ('network') { + + } + + @case ('indexer') { + + } + + @case ('profile') { + + } + + @case ('security') { + + } + + @case ('notifications') { + + } }
diff --git a/src/app/components/settings/settings.component.ts b/src/app/components/settings/settings.component.ts index 60fda416..26bef2c8 100644 --- a/src/app/components/settings/settings.component.ts +++ b/src/app/components/settings/settings.component.ts @@ -1,3 +1,4 @@ +import { AngorMediaWatcherService } from '@angor/services/media-watcher'; import { NgClass } from '@angular/common'; import { ChangeDetectionStrategy, @@ -11,16 +12,15 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatDrawer, MatSidenavModule } from '@angular/material/sidenav'; -import { AngorMediaWatcherService } from '@angor/services/media-watcher'; +import { ActivatedRoute, Router } from '@angular/router'; +import { SignerService } from 'app/services/signer.service'; import { Subject, takeUntil } from 'rxjs'; -import { SettingsProfileComponent } from './profile/profile.component'; +import { SettingsIndexerComponent } from './indexer/indexer.component'; +import { SettingsNetworkComponent } from './network/network.component'; import { SettingsNotificationsComponent } from './notifications/notifications.component'; -import { SettingsSecurityComponent } from './security/security.component'; +import { SettingsProfileComponent } from './profile/profile.component'; import { SettingsRelayComponent } from './relay/relay.component'; -import { SettingsNetworkComponent } from "./network/network.component"; -import { SettingsIndexerComponent } from "./indexer/indexer.component"; -import { SignerService } from 'app/services/signer.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { SettingsSecurityComponent } from './security/security.component'; @Component({ selector: 'settings', @@ -38,7 +38,7 @@ import { ActivatedRoute, Router } from '@angular/router'; SettingsNotificationsComponent, SettingsRelayComponent, SettingsNetworkComponent, - SettingsIndexerComponent + SettingsIndexerComponent, ], }) export class SettingsComponent implements OnInit, OnDestroy { @@ -50,48 +50,52 @@ export class SettingsComponent implements OnInit, OnDestroy { id: 'relay', icon: 'heroicons_outline:server', title: 'Relay', - description: 'Manage and configure your existing relays and update their access roles and permissions.', + description: + 'Manage and configure your existing relays and update their access roles and permissions.', }, { id: 'network', icon: 'heroicons_outline:globe-alt', title: 'Network', - description: 'Switch between mainnet and testnet for different Bitcoin network configurations.', + description: + 'Switch between mainnet and testnet for different Bitcoin network configurations.', }, { id: 'indexer', icon: 'heroicons_outline:chart-bar', title: 'Indexer', - description: 'Add, remove, and manage your indexers, including setting the primary indexer.', + description: + 'Add, remove, and manage your indexers, including setting the primary indexer.', }, { id: 'profile', icon: 'heroicons_outline:user', title: 'Profile', - description: 'Update your personal profile, manage your account details, and modify your private information.', + description: + 'Update your personal profile, manage your account details, and modify your private information.', }, { id: 'notifications', icon: 'heroicons_outline:bell', title: 'Notifications', - description: 'Control when and how youโ€™ll be notified across various communication channels.', + description: + 'Control when and how youโ€™ll be notified across various communication channels.', }, { id: 'security', icon: 'heroicons_outline:shield-check', title: 'Password Management', - description: 'Manage your password and decryption settings, including saving passwords for future decrypting sensitive information.', - } + description: + 'Manage your password and decryption settings, including saving passwords for future decrypting sensitive information.', + }, ]; - selectedPanel = 'relay'; - selectPanel(panelId: string): void { + selectPanel(panelId: string): void { this.selectedPanel = panelId; - this.router.navigate(['settings', panelId], { replaceUrl: true }); + this.router.navigate(['settings', panelId], { replaceUrl: true }); } - private _unsubscribeAll = new Subject(); constructor( @@ -100,13 +104,12 @@ export class SettingsComponent implements OnInit, OnDestroy { private _signerService: SignerService, private _route: ActivatedRoute, private router: Router - - ) { } + ) {} ngOnInit(): void { this._route.paramMap.subscribe((params) => { const id = params.get('id'); - if (id && this.panels.some(panel => panel.id === id)) { + if (id && this.panels.some((panel) => panel.id === id)) { this.selectedPanel = id; } else { this.selectedPanel = 'relay'; @@ -117,18 +120,22 @@ export class SettingsComponent implements OnInit, OnDestroy { id: 'security', icon: 'heroicons_outline:shield-check', title: 'Password Management', - description: 'Manage your password and decryption settings, including saving passwords for future decrypting sensitive information.', + description: + 'Manage your password and decryption settings, including saving passwords for future decrypting sensitive information.', }; - if (!this._signerService.isUsingSecretKey()) { - this.panels = this.panels.filter(panel => panel.id !== 'security'); - console.log("Extension used, security panel removed"); + this.panels = this.panels.filter( + (panel) => panel.id !== 'security' + ); + console.log('Extension used, security panel removed'); } else { - const panelExists = this.panels.some(panel => panel.id === 'security'); + const panelExists = this.panels.some( + (panel) => panel.id === 'security' + ); if (!panelExists) { this.panels.push(securityPanel); - console.log("Extension not used, security panel added"); + console.log('Extension not used, security panel added'); } } @@ -137,13 +144,14 @@ export class SettingsComponent implements OnInit, OnDestroy { this._angorMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({ matchingAliases }) => { - this.drawerMode = matchingAliases.includes('lg') ? 'side' : 'over'; + this.drawerMode = matchingAliases.includes('lg') + ? 'side' + : 'over'; this.drawerOpened = this.drawerMode === 'side'; this._changeDetectorRef.markForCheck(); }); } - ngOnDestroy(): void { this._unsubscribeAll.next(); this._unsubscribeAll.complete(); @@ -158,7 +166,7 @@ export class SettingsComponent implements OnInit, OnDestroy { } getPanelInfo(id: string): any { - return this.panels.find(panel => panel.id === id); + return this.panels.find((panel) => panel.id === id); } trackByFn(index: number, item: any): any { diff --git a/src/app/core/auth/auth.guard.ts b/src/app/core/auth/auth.guard.ts index 1724f6d4..5b93b69c 100644 --- a/src/app/core/auth/auth.guard.ts +++ b/src/app/core/auth/auth.guard.ts @@ -2,15 +2,14 @@ import { inject } from '@angular/core'; import { Router } from '@angular/router'; import { SignerService } from 'app/services/signer.service'; - export const authGuard = () => { - const signerService = inject(SignerService); - const router = inject(Router); + const signerService = inject(SignerService); + const router = inject(Router); - if (signerService.getPublicKey() !== "") { - return true; - } + if (signerService.getPublicKey() !== '') { + return true; + } - // Redirect to the login page - return router.parseUrl('/login'); + // Redirect to the login page + return router.parseUrl('/login'); }; diff --git a/src/app/core/navigation/navigation.service.ts b/src/app/core/navigation/navigation.service.ts index 3e3673a1..c6696472 100644 --- a/src/app/core/navigation/navigation.service.ts +++ b/src/app/core/navigation/navigation.service.ts @@ -9,12 +9,10 @@ export class NavigationService { private _navigation: ReplaySubject = new ReplaySubject(1); - get navigation$(): Observable { return this._navigation.asObservable(); } - get(): Observable { return this._httpClient.get('api/navigation').pipe( tap((navigation) => { diff --git a/src/app/interface/project.interface.ts b/src/app/interface/project.interface.ts index 8f2688a0..b4599300 100644 --- a/src/app/interface/project.interface.ts +++ b/src/app/interface/project.interface.ts @@ -4,5 +4,5 @@ export interface Project { displayName?: string; about?: string; picture?: string; - banner?:string - } + banner?: string; +} diff --git a/src/app/layout/common/notifications/notifications.component.ts b/src/app/layout/common/notifications/notifications.component.ts index 24889f2a..d6e2f5c4 100644 --- a/src/app/layout/common/notifications/notifications.component.ts +++ b/src/app/layout/common/notifications/notifications.component.ts @@ -16,7 +16,10 @@ import { MatButton, MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; - import { NostrNotification, NotificationService } from 'app/services/notification.service'; +import { + NostrNotification, + NotificationService, +} from 'app/services/notification.service'; import { SignerService } from 'app/services/signer.service'; import { Subject, takeUntil } from 'rxjs'; @@ -39,7 +42,8 @@ import { Subject, takeUntil } from 'rxjs'; }) export class NotificationsComponent implements OnInit, OnDestroy { @ViewChild('notificationsOrigin') private _notificationsOrigin: MatButton; - @ViewChild('notificationsPanel') private _notificationsPanel: TemplateRef; + @ViewChild('notificationsPanel') + private _notificationsPanel: TemplateRef; notifications: NostrNotification[] = []; unreadCount: number = 0; @@ -51,25 +55,23 @@ export class NotificationsComponent implements OnInit, OnDestroy { private _changeDetectorRef: ChangeDetectorRef, private _overlay: Overlay, private _viewContainerRef: ViewContainerRef, - private _signerService: SignerService, - + private _signerService: SignerService ) {} ngOnInit(): void { + const pubkey = this._signerService.getPublicKey(); - const pubkey = this._signerService.getPublicKey(); - - this._notificationService.subscribeToNotifications(pubkey).then(() => { - - this._notificationService.getNotificationObservable() + this._notificationService.subscribeToNotifications(pubkey).then(() => { + this._notificationService + .getNotificationObservable() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((notifications: NostrNotification[]) => { this.notifications = notifications; this._changeDetectorRef.markForCheck(); }); - - this._notificationService.getNotificationCount() + this._notificationService + .getNotificationCount() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((count: number) => { this.unreadCount = count; @@ -79,7 +81,7 @@ export class NotificationsComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { - this._unsubscribeAll.next(null); + this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } @@ -122,12 +124,24 @@ export class NotificationsComponent implements OnInit, OnDestroy { scrollStrategy: this._overlay.scrollStrategies.block(), positionStrategy: this._overlay .position() - .flexibleConnectedTo(this._notificationsOrigin._elementRef.nativeElement) + .flexibleConnectedTo( + this._notificationsOrigin._elementRef.nativeElement + ) .withLockedPosition(true) .withPush(true) .withPositions([ - { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, - { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + }, ]), }); diff --git a/src/app/layout/common/quick-chat/quick-chat.component.html b/src/app/layout/common/quick-chat/quick-chat.component.html index d4b37002..de34f45f 100644 --- a/src/app/layout/common/quick-chat/quick-chat.component.html +++ b/src/app/layout/common/quick-chat/quick-chat.component.html @@ -73,7 +73,7 @@
diff --git a/src/app/layout/common/quick-chat/quick-chat.component.ts b/src/app/layout/common/quick-chat/quick-chat.component.ts index fd71a80c..5b0796a5 100644 --- a/src/app/layout/common/quick-chat/quick-chat.component.ts +++ b/src/app/layout/common/quick-chat/quick-chat.component.ts @@ -1,3 +1,4 @@ +import { AngorScrollbarDirective } from '@angor/directives/scrollbar'; import { ScrollStrategy, ScrollStrategyOptions } from '@angular/cdk/overlay'; import { TextFieldModule } from '@angular/cdk/text-field'; import { DOCUMENT, DatePipe, NgClass, NgTemplateOutlet } from '@angular/common'; @@ -19,7 +20,6 @@ import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { AngorScrollbarDirective } from '@angor/directives/scrollbar'; import { QuickChatService } from 'app/layout/common/quick-chat/quick-chat.service'; import { Chat } from 'app/layout/common/quick-chat/quick-chat.types'; import { Subject, takeUntil } from 'rxjs'; diff --git a/src/app/layout/common/search/search.component.html b/src/app/layout/common/search/search.component.html index 63f27efa..e57294d0 100644 --- a/src/app/layout/common/search/search.component.html +++ b/src/app/layout/common/search/search.component.html @@ -2,38 +2,75 @@ -
- - - - +
+ + + + No results found! - - + + + [routerLink]="result.link" + [value]="result.name" + >
-
+
{{ result.name }}'s avatar + alt="{{ result.name }}'s avatar" + /> - +
{{ result.name - }} -
+ }} +
{{ result.about }}
@@ -42,7 +79,11 @@ -
diff --git a/src/app/layout/common/search/search.component.ts b/src/app/layout/common/search/search.component.ts index fb22e58e..6b330e5c 100644 --- a/src/app/layout/common/search/search.component.ts +++ b/src/app/layout/common/search/search.component.ts @@ -1,3 +1,4 @@ +import { angorAnimations } from '@angor/animations/public-api'; import { Overlay } from '@angular/cdk/overlay'; import { CommonModule, NgClass, NgTemplateOutlet } from '@angular/common'; import { @@ -29,11 +30,10 @@ import { MatOptionModule } from '@angular/material/core'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { DomSanitizer } from '@angular/platform-browser'; import { RouterLink } from '@angular/router'; -import { angorAnimations } from '@angor/animations/public-api'; -import { Subject, debounceTime, filter, map, takeUntil } from 'rxjs'; import { IndexedDBService } from 'app/services/indexed-db.service'; -import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { Subject, debounceTime, filter, map, takeUntil } from 'rxjs'; @Component({ selector: 'search', @@ -54,7 +54,7 @@ import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; MatFormFieldModule, MatInputModule, NgClass, - CommonModule + CommonModule, ], providers: [ { @@ -81,8 +81,7 @@ export class SearchComponent implements OnChanges, OnInit, OnDestroy { constructor( private _indexedDBService: IndexedDBService, private _sanitizer: DomSanitizer - - ) { } + ) {} @ViewChild('barSearchInput') set barSearchInput(value: ElementRef) { @@ -119,29 +118,37 @@ export class SearchComponent implements OnChanges, OnInit, OnDestroy { filter((value) => value && value.length >= this.minLength) ) .subscribe(async (value) => { - const results = await this._indexedDBService.searchUsersByMetadata(value); + const results = + await this._indexedDBService.searchUsersByMetadata(value); this.resultSets = results.map((result) => ({ label: 'Project', results: [ { - name: result.user.name || result.user.displayName || result.pubkey, + name: + result.user.name || + result.user.displayName || + result.pubkey, pubkey: result.pubkey, username: result.user.username || '', website: result.user.website || '', - about: result.user.about ? result.user.about.replace(/<\/?[^>]+(>|$)/g, '') : '', + about: result.user.about + ? result.user.about.replace( + /<\/?[^>]+(>|$)/g, + '' + ) + : '', avatar: result.user.picture || null, banner: result.user.banner || null, - link: `/profile/${result.pubkey}` - } - ] + link: `/profile/${result.pubkey}`, + }, + ], })); this.search.next(this.resultSets); }); } - ngOnDestroy(): void { this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); @@ -181,5 +188,4 @@ export class SearchComponent implements OnChanges, OnInit, OnDestroy { target.onerror = null; target.src = 'images/avatars/avatar-placeholder.png'; } - } diff --git a/src/app/layout/common/settings/settings.component.ts b/src/app/layout/common/settings/settings.component.ts index 54da3caf..28ac1e32 100644 --- a/src/app/layout/common/settings/settings.component.ts +++ b/src/app/layout/common/settings/settings.component.ts @@ -1,9 +1,3 @@ -import { NgClass } from '@angular/common'; -import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router } from '@angular/router'; import { AngorDrawerComponent } from '@angor/components/drawer'; import { AngorConfig, @@ -12,6 +6,12 @@ import { Theme, Themes, } from '@angor/services/config'; +import { NgClass } from '@angular/common'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Router } from '@angular/router'; import { Subject, takeUntil } from 'rxjs'; @@ -52,13 +52,11 @@ export class SettingsComponent implements OnInit, OnDestroy { themes: Themes; private _unsubscribeAll: Subject = new Subject(); - constructor( private _router: Router, private _angorConfigService: AngorConfigService ) {} - ngOnInit(): void { // Subscribe to config changes this._angorConfigService.config$ @@ -76,7 +74,6 @@ export class SettingsComponent implements OnInit, OnDestroy { this._unsubscribeAll.complete(); } - setLayout(layout: string): void { // Clear the 'layout' query param to allow layout changes this._router @@ -92,12 +89,10 @@ export class SettingsComponent implements OnInit, OnDestroy { }); } - setScheme(scheme: Scheme): void { this._angorConfigService.config = { scheme }; } - setTheme(theme: Theme): void { this._angorConfigService.config = { theme }; } diff --git a/src/app/layout/common/update/update.component.html b/src/app/layout/common/update/update.component.html index 235f9ef0..d645f299 100644 --- a/src/app/layout/common/update/update.component.html +++ b/src/app/layout/common/update/update.component.html @@ -1,7 +1,9 @@ - diff --git a/src/app/layout/common/update/update.component.ts b/src/app/layout/common/update/update.component.ts index 0f76ca29..a8c58893 100644 --- a/src/app/layout/common/update/update.component.ts +++ b/src/app/layout/common/update/update.component.ts @@ -1,9 +1,13 @@ -import { CommonModule, DatePipe, NgClass, NgTemplateOutlet } from '@angular/common'; +import { + CommonModule, + DatePipe, + NgClass, + NgTemplateOutlet, +} from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - inject, Input, ViewEncapsulation, } from '@angular/core'; @@ -28,7 +32,7 @@ import { NewVersionCheckerService } from 'app/services/update.service'; NgTemplateOutlet, RouterLink, DatePipe, - CommonModule + CommonModule, ], }) export class UpdateComponent { diff --git a/src/app/layout/common/user/user.component.html b/src/app/layout/common/user/user.component.html index 5f662f8c..ed4286a3 100644 --- a/src/app/layout/common/user/user.component.html +++ b/src/app/layout/common/user/user.component.html @@ -1,7 +1,12 @@ @@ -27,7 +34,9 @@ diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 85a87c59..9c0b205b 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -1,3 +1,11 @@ +import { + AngorConfig, + AngorConfigService, + Scheme, + Theme, + Themes, +} from '@angor/services/config'; +import { CommonModule, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, ChangeDetectorRef, @@ -6,18 +14,16 @@ import { OnInit, ViewEncapsulation, } from '@angular/core'; -import { AngorConfig, AngorConfigService, Scheme, Theme, Themes } from '@angor/services/config'; -import { Subject, takeUntil } from 'rxjs'; -import { Router } from '@angular/router'; -import { SignerService } from 'app/services/signer.service'; -import { NgClass, CommonModule } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { MetadataService } from 'app/services/metadata.service'; -import { IndexedDBService } from 'app/services/indexed-db.service'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +import { IndexedDBService } from 'app/services/indexed-db.service'; +import { MetadataService } from 'app/services/metadata.service'; +import { SignerService } from 'app/services/signer.service'; +import { Subject, takeUntil } from 'rxjs'; @Component({ selector: 'user', @@ -31,7 +37,7 @@ import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; MatIconModule, NgClass, MatDividerModule, - CommonModule + CommonModule, ], }) export class UserComponent implements OnInit, OnDestroy { @@ -47,7 +53,6 @@ export class UserComponent implements OnInit, OnDestroy { theme: string; themes: Themes; - constructor( private _changeDetectorRef: ChangeDetectorRef, private _router: Router, @@ -56,8 +61,7 @@ export class UserComponent implements OnInit, OnDestroy { private _signerService: SignerService, private _indexedDBService: IndexedDBService, private sanitizer: DomSanitizer - - ) { } + ) {} ngOnInit(): void { this.loadUserProfile(); @@ -68,20 +72,22 @@ export class UserComponent implements OnInit, OnDestroy { this.config = config; this._changeDetectorRef.detectChanges(); }); - this.loadUserProfile(); + this.loadUserProfile(); - this._indexedDBService.getMetadataStream() + this._indexedDBService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === this.user?.pubkey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === this.user?.pubkey + ) { this.metadata = updatedMetadata.metadata; this._changeDetectorRef.detectChanges(); } }); } - - ngOnDestroy(): void { this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); @@ -99,29 +105,32 @@ export class UserComponent implements OnInit, OnDestroy { return; } - this.user = { pubkey: publicKey }; try { - - const metadata = await this._metadataService.fetchMetadataWithCache(publicKey); + const metadata = + await this._metadataService.fetchMetadataWithCache(publicKey); if (metadata) { this.metadata = metadata; this._changeDetectorRef.detectChanges(); } - - this._metadataService.getMetadataStream() + this._metadataService + .getMetadataStream() .pipe(takeUntil(this._unsubscribeAll)) .subscribe((updatedMetadata) => { - if (updatedMetadata && updatedMetadata.pubkey === publicKey) { + if ( + updatedMetadata && + updatedMetadata.pubkey === publicKey + ) { this.metadata = updatedMetadata; this._changeDetectorRef.detectChanges(); } }); } catch (error) { console.error('Failed to load profile data:', error); - this.errorMessage = 'Failed to load profile data. Please try again later.'; + this.errorMessage = + 'Failed to load profile data. Please try again later.'; this._changeDetectorRef.detectChanges(); } finally { this.isLoading = false; @@ -154,5 +163,5 @@ export class UserComponent implements OnInit, OnDestroy { getSafeUrl(url: string): SafeUrl { return this.sanitizer.bypassSecurityTrustUrl(url); - } + } } diff --git a/src/app/layout/layout.component.html b/src/app/layout/layout.component.html index aaba3aa3..bc2a5df7 100644 --- a/src/app/layout/layout.component.html +++ b/src/app/layout/layout.component.html @@ -7,9 +7,7 @@ } - @if (layout === 'classic') { } - diff --git a/src/app/layout/layout.component.ts b/src/app/layout/layout.component.ts index 256a4962..49953410 100644 --- a/src/app/layout/layout.component.ts +++ b/src/app/layout/layout.component.ts @@ -1,3 +1,7 @@ +import { AngorConfig, AngorConfigService } from '@angor/services/config'; +import { AngorMediaWatcherService } from '@angor/services/media-watcher'; +import { AngorPlatformService } from '@angor/services/platform'; +import { ANGOR_VERSION } from '@angor/version'; import { DOCUMENT } from '@angular/common'; import { Component, @@ -8,17 +12,12 @@ import { ViewEncapsulation, } from '@angular/core'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; -import { AngorConfig, AngorConfigService } from '@angor/services/config'; -import { AngorMediaWatcherService } from '@angor/services/media-watcher'; -import { AngorPlatformService } from '@angor/services/platform'; -import { ANGOR_VERSION } from '@angor/version'; import { Subject, combineLatest, filter, map, takeUntil } from 'rxjs'; import { SettingsComponent } from './common/settings/settings.component'; import { EmptyLayoutComponent } from './layouts/empty/empty.component'; import { ModernLayoutComponent } from './layouts/horizontal/modern/modern.component'; import { ClassicLayoutComponent } from './layouts/vertical/classic/classic.component'; - @Component({ selector: 'layout', templateUrl: './layout.component.html', @@ -39,7 +38,6 @@ export class LayoutComponent implements OnInit, OnDestroy { theme: string; private _unsubscribeAll: Subject = new Subject(); - constructor( private _activatedRoute: ActivatedRoute, @Inject(DOCUMENT) private _document: any, @@ -48,11 +46,9 @@ export class LayoutComponent implements OnInit, OnDestroy { private _angorConfigService: AngorConfigService, private _angorMediaWatcherService: AngorMediaWatcherService, private _angorPlatformService: AngorPlatformService - ) { } - + ) {} ngOnInit(): void { - combineLatest([ this._angorConfigService.config$, this._angorMediaWatcherService.onMediaQueryChange$([ @@ -68,9 +64,7 @@ export class LayoutComponent implements OnInit, OnDestroy { theme: config.theme, }; - if (config.scheme === 'auto') { - options.scheme = mql.breakpoints[ '(prefers-color-scheme: dark)' ] @@ -82,70 +76,55 @@ export class LayoutComponent implements OnInit, OnDestroy { }) ) .subscribe((options) => { - this.scheme = options.scheme; this.theme = options.theme; - this._updateScheme(); this._updateTheme(); }); - this._angorConfigService.config$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((config: AngorConfig) => { - this.config = config; - this._updateLayout(); }); - this._router.events .pipe( filter((event) => event instanceof NavigationEnd), takeUntil(this._unsubscribeAll) ) .subscribe(() => { - this._updateLayout(); }); - this._renderer2.setAttribute( this._document.querySelector('[ng-version]'), 'angor-version', ANGOR_VERSION ); - this._renderer2.addClass( this._document.body, this._angorPlatformService.osName ); } - ngOnDestroy(): void { - this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } private _updateLayout(): void { - let route = this._activatedRoute; while (route.firstChild) { route = route.firstChild; } - this.layout = this.config.layout; - - const layoutFromQueryParam = route.snapshot.queryParamMap.get('layout'); if (layoutFromQueryParam) { this.layout = layoutFromQueryParam; @@ -156,29 +135,23 @@ export class LayoutComponent implements OnInit, OnDestroy { const paths = route.pathFromRoot; paths.forEach((path) => { - if ( path.routeConfig && path.routeConfig.data && path.routeConfig.data.layout ) { - this.layout = path.routeConfig.data.layout; } }); } private _updateScheme(): void { - this._document.body.classList.remove('light', 'dark'); - this._document.body.classList.add(this.scheme); } - private _updateTheme(): void { - this._document.body.classList.forEach((className: string) => { if (className.startsWith('theme-')) { this._document.body.classList.remove( @@ -188,7 +161,6 @@ export class LayoutComponent implements OnInit, OnDestroy { } }); - this._document.body.classList.add(this.theme); } } diff --git a/src/app/layout/layouts/empty/empty.component.ts b/src/app/layout/layouts/empty/empty.component.ts index 93d19dd6..9a09998e 100644 --- a/src/app/layout/layouts/empty/empty.component.ts +++ b/src/app/layout/layouts/empty/empty.component.ts @@ -1,6 +1,6 @@ +import { AngorLoadingBarComponent } from '@angor/components/loading-bar'; import { Component, OnDestroy, ViewEncapsulation } from '@angular/core'; import { RouterOutlet } from '@angular/router'; -import { AngorLoadingBarComponent } from '@angor/components/loading-bar'; import { Subject } from 'rxjs'; @Component({ diff --git a/src/app/layout/layouts/horizontal/modern/modern.component.ts b/src/app/layout/layouts/horizontal/modern/modern.component.ts index 0d1770ac..eaf834d4 100644 --- a/src/app/layout/layouts/horizontal/modern/modern.component.ts +++ b/src/app/layout/layouts/horizontal/modern/modern.component.ts @@ -1,7 +1,3 @@ -import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { AngorFullscreenComponent } from '@angor/components/fullscreen'; import { AngorLoadingBarComponent } from '@angor/components/loading-bar'; import { @@ -10,6 +6,11 @@ import { AngorVerticalNavigationComponent, } from '@angor/components/navigation'; import { AngorMediaWatcherService } from '@angor/services/media-watcher'; +import { ANGOR_VERSION } from '@angor/version'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { NavigationService } from 'app/core/navigation/navigation.service'; import { Navigation } from 'app/core/navigation/navigation.types'; import { NotificationsComponent } from 'app/layout/common/notifications/notifications.component'; @@ -17,8 +18,7 @@ import { QuickChatComponent } from 'app/layout/common/quick-chat/quick-chat.comp import { SearchComponent } from 'app/layout/common/search/search.component'; import { UserComponent } from 'app/layout/common/user/user.component'; import { Subject, takeUntil } from 'rxjs'; -import { ANGOR_VERSION } from '@angor/version'; -import { UpdateComponent } from "../../../common/update/update.component"; +import { UpdateComponent } from '../../../common/update/update.component'; @Component({ selector: 'modern-layout', @@ -26,19 +26,19 @@ import { UpdateComponent } from "../../../common/update/update.component"; encapsulation: ViewEncapsulation.None, standalone: true, imports: [ - AngorLoadingBarComponent, - AngorVerticalNavigationComponent, - AngorHorizontalNavigationComponent, - MatButtonModule, - MatIconModule, - AngorFullscreenComponent, - SearchComponent, - NotificationsComponent, - UserComponent, - RouterOutlet, - QuickChatComponent, - UpdateComponent -], + AngorLoadingBarComponent, + AngorVerticalNavigationComponent, + AngorHorizontalNavigationComponent, + MatButtonModule, + MatIconModule, + AngorFullscreenComponent, + SearchComponent, + NotificationsComponent, + UserComponent, + RouterOutlet, + QuickChatComponent, + UpdateComponent, + ], }) export class ModernLayoutComponent implements OnInit, OnDestroy { isScreenSmall: boolean; @@ -57,46 +57,36 @@ export class ModernLayoutComponent implements OnInit, OnDestroy { private _angorNavigationService: AngorNavigationService ) {} - get currentYear(): number { return new Date().getFullYear(); } - ngOnInit(): void { - this._navigationService.navigation$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((navigation: Navigation) => { this.navigation = navigation; }); - this._angorMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({ matchingAliases }) => { - this.isScreenSmall = !matchingAliases.includes('md'); }); } - ngOnDestroy(): void { - this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } - toggleNavigation(name: string): void { - const navigation = this._angorNavigationService.getComponent( name ); if (navigation) { - navigation.toggle(); } } diff --git a/src/app/layout/layouts/vertical/classic/classic.component.html b/src/app/layout/layouts/vertical/classic/classic.component.html index a155a2e0..0e1c32c8 100644 --- a/src/app/layout/layouts/vertical/classic/classic.component.html +++ b/src/app/layout/layouts/vertical/classic/classic.component.html @@ -74,7 +74,7 @@ class="bg-card relative z-49 flex h-14 w-full flex-0 items-center justify-start border-t px-4 dark:bg-transparent md:px-6 print:hidden" > Angor Hub © {{ currentYear }} - version {{version}}Angor Hub © {{ currentYear }} - version {{ version }}
diff --git a/src/app/layout/layouts/vertical/classic/classic.component.ts b/src/app/layout/layouts/vertical/classic/classic.component.ts index 0cc0c85d..49b5366b 100644 --- a/src/app/layout/layouts/vertical/classic/classic.component.ts +++ b/src/app/layout/layouts/vertical/classic/classic.component.ts @@ -1,7 +1,3 @@ -import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { MatIconModule } from '@angular/material/icon'; -import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { AngorFullscreenComponent } from '@angor/components/fullscreen'; import { AngorLoadingBarComponent } from '@angor/components/loading-bar'; import { @@ -9,6 +5,11 @@ import { AngorVerticalNavigationComponent, } from '@angor/components/navigation'; import { AngorMediaWatcherService } from '@angor/services/media-watcher'; +import { ANGOR_VERSION } from '@angor/version'; +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; import { NavigationService } from 'app/core/navigation/navigation.service'; import { Navigation } from 'app/core/navigation/navigation.types'; import { NotificationsComponent } from 'app/layout/common/notifications/notifications.component'; @@ -16,8 +17,7 @@ import { QuickChatComponent } from 'app/layout/common/quick-chat/quick-chat.comp import { SearchComponent } from 'app/layout/common/search/search.component'; import { UserComponent } from 'app/layout/common/user/user.component'; import { Subject, takeUntil } from 'rxjs'; -import { ANGOR_VERSION } from '@angor/version'; -import { UpdateComponent } from "../../../common/update/update.component"; +import { UpdateComponent } from '../../../common/update/update.component'; @Component({ selector: 'classic-layout', @@ -25,18 +25,18 @@ import { UpdateComponent } from "../../../common/update/update.component"; encapsulation: ViewEncapsulation.None, standalone: true, imports: [ - AngorLoadingBarComponent, - AngorVerticalNavigationComponent, - MatButtonModule, - MatIconModule, - AngorFullscreenComponent, - SearchComponent, - NotificationsComponent, - UserComponent, - RouterOutlet, - QuickChatComponent, - UpdateComponent -], + AngorLoadingBarComponent, + AngorVerticalNavigationComponent, + MatButtonModule, + MatIconModule, + AngorFullscreenComponent, + SearchComponent, + NotificationsComponent, + UserComponent, + RouterOutlet, + QuickChatComponent, + UpdateComponent, + ], }) export class ClassicLayoutComponent implements OnInit, OnDestroy { isScreenSmall: boolean; @@ -52,45 +52,36 @@ export class ClassicLayoutComponent implements OnInit, OnDestroy { private _angorNavigationService: AngorNavigationService ) {} - get currentYear(): number { return new Date().getFullYear(); } - ngOnInit(): void { - this._navigationService.navigation$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe((navigation: Navigation) => { this.navigation = navigation; }); - this._angorMediaWatcherService.onMediaChange$ .pipe(takeUntil(this._unsubscribeAll)) .subscribe(({ matchingAliases }) => { - this.isScreenSmall = !matchingAliases.includes('md'); }); } ngOnDestroy(): void { - this._unsubscribeAll.next(null); this._unsubscribeAll.complete(); } - toggleNavigation(name: string): void { - const navigation = this._angorNavigationService.getComponent( name ); if (navigation) { - navigation.toggle(); } } diff --git a/src/app/layout/navigation/api.ts b/src/app/layout/navigation/api.ts index d7f7e389..a06e91d8 100644 --- a/src/app/layout/navigation/api.ts +++ b/src/app/layout/navigation/api.ts @@ -1,6 +1,6 @@ -import { Injectable } from '@angular/core'; import { AngorNavigationItem } from '@angor/components/navigation'; import { AngorMockApiService } from '@angor/lib/mock-api'; +import { Injectable } from '@angular/core'; import { compactNavigation, defaultNavigation, diff --git a/src/app/layout/navigation/data.ts b/src/app/layout/navigation/data.ts index 579ccd7e..02ffd2a9 100644 --- a/src/app/layout/navigation/data.ts +++ b/src/app/layout/navigation/data.ts @@ -3,152 +3,152 @@ import { AngorNavigationItem } from '@angor/components/navigation'; export const defaultNavigation: AngorNavigationItem[] = [ { - id : 'home', + id: 'home', title: 'Home', - type : 'basic', - icon : 'heroicons_outline:home', - link : '/home' + type: 'basic', + icon: 'heroicons_outline:home', + link: '/home', }, { - id : 'explore', + id: 'explore', title: 'Explore', - type : 'basic', - icon : 'heroicons_outline:magnifying-glass', - link : '/explore' + type: 'basic', + icon: 'heroicons_outline:magnifying-glass', + link: '/explore', }, { - id : 'settings', + id: 'settings', title: 'Settings', - type : 'basic', - icon : 'heroicons_outline:cog-6-tooth', - link : '/settings' + type: 'basic', + icon: 'heroicons_outline:cog-6-tooth', + link: '/settings', }, { - id : 'profile', + id: 'profile', title: 'Profile', - type : 'basic', - icon : 'heroicons_outline:user', - link : '/profile' + type: 'basic', + icon: 'heroicons_outline:user', + link: '/profile', }, { - id : 'chat', + id: 'chat', title: 'Chat', - type : 'basic', - icon : 'heroicons_outline:chat-bubble-left-right', - link : '/chat' - } + type: 'basic', + icon: 'heroicons_outline:chat-bubble-left-right', + link: '/chat', + }, ]; export const compactNavigation: AngorNavigationItem[] = [ { - id : 'home', + id: 'home', title: 'Home', - type : 'basic', - icon : 'heroicons_outline:home', - link : '/home' + type: 'basic', + icon: 'heroicons_outline:home', + link: '/home', }, { - id : 'explore', + id: 'explore', title: 'Explore', - type : 'basic', - icon : 'heroicons_outline:magnifying-glass', - link : '/explore' + type: 'basic', + icon: 'heroicons_outline:magnifying-glass', + link: '/explore', }, { - id : 'settings', + id: 'settings', title: 'Settings', - type : 'basic', - icon : 'heroicons_outline:cog-6-tooth', - link : '/settings' + type: 'basic', + icon: 'heroicons_outline:cog-6-tooth', + link: '/settings', }, { - id : 'profile', + id: 'profile', title: 'Profile', - type : 'basic', - icon : 'heroicons_outline:user', - link : '/profile' + type: 'basic', + icon: 'heroicons_outline:user', + link: '/profile', }, { - id : 'chat', + id: 'chat', title: 'Chat', - type : 'basic', - icon : 'heroicons_outline:chat-bubble-left-right', - link : '/chat' - } + type: 'basic', + icon: 'heroicons_outline:chat-bubble-left-right', + link: '/chat', + }, ]; export const futuristicNavigation: AngorNavigationItem[] = [ { - id : 'home', + id: 'home', title: 'Home', - type : 'basic', - icon : 'heroicons_outline:home', - link : '/home' + type: 'basic', + icon: 'heroicons_outline:home', + link: '/home', }, { - id : 'explore', + id: 'explore', title: 'Explore', - type : 'basic', - icon : 'heroicons_outline:magnifying-glass', - link : '/explore' + type: 'basic', + icon: 'heroicons_outline:magnifying-glass', + link: '/explore', }, { - id : 'settings', + id: 'settings', title: 'Settings', - type : 'basic', - icon : 'heroicons_outline:cog-6-tooth', - link : '/settings' + type: 'basic', + icon: 'heroicons_outline:cog-6-tooth', + link: '/settings', }, { - id : 'profile', + id: 'profile', title: 'Profile', - type : 'basic', - icon : 'heroicons_outline:user', - link : '/profile' + type: 'basic', + icon: 'heroicons_outline:user', + link: '/profile', }, { - id : 'chat', + id: 'chat', title: 'Chat', - type : 'basic', - icon : 'heroicons_outline:chat-bubble-left-right', - link : '/chat' - } + type: 'basic', + icon: 'heroicons_outline:chat-bubble-left-right', + link: '/chat', + }, ]; export const horizontalNavigation: AngorNavigationItem[] = [ { - id : 'home', + id: 'home', title: 'Home', - type : 'basic', - icon : 'heroicons_outline:home', - link : '/home' + type: 'basic', + icon: 'heroicons_outline:home', + link: '/home', }, { - id : 'explore', + id: 'explore', title: 'Explore', - type : 'basic', - icon : 'heroicons_outline:magnifying-glass', - link : '/explore' + type: 'basic', + icon: 'heroicons_outline:magnifying-glass', + link: '/explore', }, { - id : 'settings', + id: 'settings', title: 'Settings', - type : 'basic', - icon : 'heroicons_outline:cog-6-tooth', - link : '/settings' + type: 'basic', + icon: 'heroicons_outline:cog-6-tooth', + link: '/settings', }, { - id : 'profile', + id: 'profile', title: 'Profile', - type : 'basic', - icon : 'heroicons_outline:user', - link : '/profile' + type: 'basic', + icon: 'heroicons_outline:user', + link: '/profile', }, { - id : 'chat', + id: 'chat', title: 'Chat', - type : 'basic', - icon : 'heroicons_outline:chat-bubble-left-right', - link : '/chat' - } + type: 'basic', + icon: 'heroicons_outline:chat-bubble-left-right', + link: '/chat', + }, ]; diff --git a/src/app/layout/navigation/navigation.services.ts b/src/app/layout/navigation/navigation.services.ts index b261e26b..eb17eb4f 100644 --- a/src/app/layout/navigation/navigation.services.ts +++ b/src/app/layout/navigation/navigation.services.ts @@ -1,5 +1,3 @@ -import { NavigationApi } from "./api"; +import { NavigationApi } from './api'; - export const navigationServices = [ - NavigationApi, -]; +export const navigationServices = [NavigationApi]; diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts index 86c57471..0b32b630 100644 --- a/src/app/services/auth.service.ts +++ b/src/app/services/auth.service.ts @@ -1,23 +1,21 @@ import { Injectable } from '@angular/core'; - import { Router } from '@angular/router'; +import { Router } from '@angular/router'; import { SignerService } from './signer.service'; - @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class AuthService { - - constructor( - private signerService: SignerService, - private router: Router - ) { } + constructor( + private signerService: SignerService, + private router: Router + ) {} isLoggedIn() { if (this.signerService.getPublicKey()) { return true; } - this.router.navigate(['/sign-in']); + this.router.navigate(['/login']); return false; } } diff --git a/src/app/services/content-parser.service.ts b/src/app/services/content-parser.service.ts deleted file mode 100644 index fe98144a..00000000 --- a/src/app/services/content-parser.service.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Injectable } from '@angular/core'; -import { SignerService } from './signer.service'; - - - -@Injectable({ - providedIn: 'root' -}) -export class ContentParserService { - - constructor( - private signerService: SignerService - ) { } - -} diff --git a/src/app/services/emoji.service.ts b/src/app/services/emoji.service.ts deleted file mode 100644 index 131e029e..00000000 --- a/src/app/services/emoji.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; - -@Injectable({ - providedIn: 'root' -}) -export class EmojiService { - private readonly EMOJI_URL = 'data/emoji.json'; - - constructor(private http: HttpClient) { - console.log('EmojiService initialized'); - } - - getEmojis(): Observable { - console.log('Fetching emojis from:', this.EMOJI_URL); - return this.http.get(this.EMOJI_URL); - } -} diff --git a/src/app/services/event-emitter.service.ts b/src/app/services/event-emitter.service.ts deleted file mode 100644 index 971862f6..00000000 --- a/src/app/services/event-emitter.service.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Injectable } from '@angular/core'; - -@Injectable({ - providedIn: 'root' -}) -export class EventEmitterService {} \ No newline at end of file diff --git a/src/app/services/event.service.ts b/src/app/services/event.service.ts index 9ffb57a2..86a14b5a 100644 --- a/src/app/services/event.service.ts +++ b/src/app/services/event.service.ts @@ -1,9 +1,652 @@ import { Injectable } from '@angular/core'; +import { hexToBytes } from '@noble/hashes/utils'; +import { NewEvent } from 'app/types/NewEvent'; +import { Filter, finalizeEvent, NostrEvent } from 'nostr-tools'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; +import { MetadataService } from './metadata.service'; +import { RelayService } from './relay.service'; +import { SignerService } from './signer.service'; + +interface Job { + eventId: string; + jobType: 'replies' | 'likes' | 'zaps' | 'reposts'; +} @Injectable({ - providedIn: 'root' + providedIn: 'root', }) -export class EventService { +export class PaginatedEventService { + private eventsSubject = new BehaviorSubject([]); + private isLoading = new BehaviorSubject(false); + private lastLoadedEventTime: number | null = null; + private pageSize = 10; + private noMoreEvents = new BehaviorSubject(false); + private seenEventIds = new Set(); + + private likesMap = new Map(); + private repliesMap = new Map(); + private zapsMap = new Map(); + private repostsMap = new Map(); + private hasLikedMap = new Map(); + private hasRepostedMap = new Map(); + + private jobQueue: Job[] = []; + private isProcessingQueue = false; + + myLikedNoteIds: string[] = []; + + constructor( + private relayService: RelayService, + private signerService: SignerService, + private metadataService: MetadataService + ) { + this.clearEvents(); + + this.getMyLikes().then(() => {}).catch(error => { + console.error('Failed to load user likes:', error); + }); + } + + + async subscribeToEvents(pubkeys: string[]): Promise { + await this.relayService.ensureConnectedRelays(); + const connectedRelays = this.relayService.getConnectedRelays().slice(0, 3); + + if (!connectedRelays || connectedRelays.length === 0) { + console.error('No connected relays available.'); + return; + } + + const filters: Filter[] = [ + { + kinds: [1], + authors: pubkeys, + limit: this.pageSize, + }, + { + '#p': pubkeys, + limit: 1, + }, + ]; + + this.relayService.getPool().subscribeMany(connectedRelays, filters, { + onevent: (event: NostrEvent) => { + if (!this.isReply(event)) { + this.handleNewOrUpdatedEvent(event); + } + + const parentEventId = this.getParentEventId(event); + if (parentEventId) { + this.enqueueJob(parentEventId, 'replies'); + } + + switch (event.kind) { + case 7: + this.enqueueJob(parentEventId, 'likes'); + break; + case 9735: + this.enqueueJob(parentEventId, 'zaps'); + break; + case 6: + this.enqueueJob(parentEventId, 'reposts'); + break; + default: + } + }, + oneose: () => { + + }, + }); + } + + private getParentEventId(event: NostrEvent): string | null { + const replyTag = event.tags.find((tag) => tag[0] === 'e'); + return replyTag ? replyTag[1] : null; + } + + private async handleNewOrUpdatedEvent(event: NostrEvent): Promise { + switch (event.kind) { + case 1: + if (!this.seenEventIds.has(event.id)) { + this.seenEventIds.add(event.id); + const newEvent = await this.createNewEvent(event); + const currentEvents = this.eventsSubject.getValue(); + this.eventsSubject.next( + [newEvent, ...currentEvents].sort( + (a, b) => b.createdAt - a.createdAt + ) + ); + this.updateEventInSubject(event.id); + } + break; + + case 7: + this.handleLikeEvent(event); + break; + + case 9735: + this.handleZapEvent(event); + break; + + case 6: + this.handleRepostEvent(event); + break; + + case 4: + this.handleReplyEvent(event); + break; + + default: + } + } + + private handleLikeEvent(event: NostrEvent): void { + const eventId = event.tags.find((tag) => tag[0] === 'e')?.[1]; + if (eventId) { + const currentEvents = this.eventsSubject.getValue(); + const updatedEvents = currentEvents.map((e) => { + if (e.id === eventId) { + e.likeCount += 1; + e.likers = [...(e.likers || []), event.pubkey]; + } + return e; + }); + this.eventsSubject.next(updatedEvents); + } + } + + private handleZapEvent(event: NostrEvent): void { + const eventId = event.tags.find((tag) => tag[0] === 'e')?.[1]; + if (eventId) { + const currentEvents = this.eventsSubject.getValue(); + const updatedEvents = currentEvents.map((e) => { + if (e.id === eventId) { + e.zapCount += 1; + e.zappers = [...(e.zappers || []), event.pubkey]; + } + return e; + }); + this.eventsSubject.next(updatedEvents); + } + } + + private handleRepostEvent(event: NostrEvent): void { + const eventId = event.tags.find((tag) => tag[0] === 'e')?.[1]; + if (eventId) { + const currentEvents = this.eventsSubject.getValue(); + const updatedEvents = currentEvents.map((e) => { + if (e.id === eventId) { + e.repostCount += 1; + e.reposters = [...(e.reposters || []), event.pubkey]; + } + return e; + }); + this.eventsSubject.next(updatedEvents); + } + } + + private async handleReplyEvent(event: NostrEvent): Promise { + const eventId = event.tags.find((tag) => tag[0] === 'e')?.[1]; + if (eventId) { + const replyEvent = await this.createNewEvent(event); + const currentEvents = this.eventsSubject.getValue(); + const updatedEvents = currentEvents.map((e) => { + if (e.id === eventId) { + e.replyCount += 1; + e.replies = [...(e.replies || []), replyEvent]; + } + return e; + }); + this.eventsSubject.next(updatedEvents); + } + } + + private isReply(event: NostrEvent): boolean { + const replyTags = event.tags.filter( + (tag) => tag[0] === 'e' || tag[0] === 'p' + ); + return replyTags.length > 0; + } + + private async getMyLikes(): Promise { + const myLikesFilter: Filter = { + kinds: [7], + authors: [this.signerService.getPublicKey()] + }; + + try { + const likeEvents = await this.fetchFilteredEvents(myLikesFilter); + likeEvents.forEach((like) => { + const eventIdTag = like.tags.find(tag => tag[0] === 'e'); + if (eventIdTag) { + const eventId = eventIdTag[1]; + this.myLikedNoteIds.push(eventId); + } + }); + + return this.myLikedNoteIds; + } catch (error) { + console.error('Failed to get user likes:', error); + return []; + } + } + + async loadMoreEvents(pubkeys: string[]): Promise { + if (this.isLoading.value || this.noMoreEvents.value) return; + + this.isLoading.next(true); + + const filter: Filter = { + authors: pubkeys, + kinds: [1], + until: this.lastLoadedEventTime || Math.floor(Date.now() / 1000), + limit: this.pageSize, + }; + + try { + const events = await this.fetchFilteredEvents(filter); + + if (events.length < this.pageSize) { + this.noMoreEvents.next(true); + } + + if (events.length > 0) { + this.lastLoadedEventTime = events[events.length - 1].created_at; + + const uniqueEvents = events.filter( + (event) => !this.seenEventIds.has(event.id) && !this.isReply(event) + ); + uniqueEvents.forEach((event) => this.seenEventIds.add(event.id)); + + const newEvents = await Promise.all( + uniqueEvents.map((event) => this.createNewEvent(event)) + ); + + this.eventsSubject.next( + [...this.eventsSubject.getValue(), ...newEvents].sort( + (a, b) => b.createdAt - a.createdAt + ) + ); + } else { + this.noMoreEvents.next(true); + } + } catch (error) { + console.error('Error loading more events:', error); + } finally { + this.isLoading.next(false); + } + } + + private async fetchFilteredEvents(filter: Filter): Promise { + await this.relayService.ensureConnectedRelays(); + const connectedRelays = this.relayService.getConnectedRelays(); + + const eventMap = new Map(); + const pool = this.relayService.getPool(); + + await Promise.all( + connectedRelays.map(async (relay) => { + const events = await pool.querySync([relay], filter); + events.forEach((event) => { + if (!eventMap.has(event.id)) { + eventMap.set(event.id, event); + } + }); + }) + ); + + return Array.from(eventMap.values()); + } + + private async createNewEvent(event: NostrEvent): Promise { + const newEvent = new NewEvent( + event.id, + event.kind, + event.pubkey, + event.content, + event.id, + event.created_at, + event.tags + ); + + this.enqueueJob(event.id, 'replies'); + this.enqueueJob(event.id, 'likes'); + this.enqueueJob(event.id, 'reposts'); + this.enqueueJob(event.id, 'zaps'); + await this.processJobQueue(); + + newEvent.likedByMe = this.myLikedNoteIds.includes(event.id); + + const metadata = await this.metadataService.fetchMetadataWithCache( + event.pubkey + ); + if (metadata) { + newEvent.username = metadata.name || newEvent.npub; + newEvent.picture = metadata.picture || '/images/avatars/avatar-placeholder.png'; + } + + return newEvent; + } + + private enqueueJob( + eventId: string, + jobType: 'replies' | 'likes' | 'zaps' | 'reposts' + ): void { + if ( + !this.jobQueue.some( + (job) => job.eventId === eventId && job.jobType === jobType + ) + ) { + this.jobQueue.push({ eventId, jobType }); + if (!this.isProcessingQueue) { + this.processJobQueue(); + } + } + } + + private async processJobQueue(): Promise { + if (this.isProcessingQueue) return; + this.isProcessingQueue = true; + + const activeJobs: Promise[] = []; + while (this.jobQueue.length > 0 || activeJobs.length > 0) { + while (this.jobQueue.length > 0 && activeJobs.length < 10) { + const job = this.jobQueue.shift(); + if (!job) break; + + const jobPromise = this.processJob(job); + activeJobs.push(jobPromise); + + jobPromise + .then(() => { + activeJobs.splice(activeJobs.indexOf(jobPromise), 1); + }) + .catch((error) => { + console.error('Error processing job:', error); + activeJobs.splice(activeJobs.indexOf(jobPromise), 1); + }); + } + await Promise.race(activeJobs); + } + + this.isProcessingQueue = false; + } + + private async processJob(job: Job): Promise { + switch (job.jobType) { + case 'replies': + const replies = await this.fetchReplies(job.eventId); + this.repliesMap.set(job.eventId, replies); + break; + case 'likes': + const likers = await this.getLikers(job.eventId); + this.likesMap.set(job.eventId, likers); + break; + case 'zaps': + const zappers = await this.getZappers(job.eventId); + this.zapsMap.set(job.eventId, zappers); + break; + case 'reposts': + const reposters = await this.getReposters(job.eventId); + this.repostsMap.set(job.eventId, reposters); + break; + } + + this.updateEventInSubject(job.eventId); + } + + private updateEventInSubject(eventId: string): void { + const currentEvents = this.eventsSubject.getValue(); + const updatedEvents = currentEvents.map((event) => { + if (event.id === eventId) { + event.replyCount = this.getRepliesCount(eventId); + event.replies = this.repliesMap.get(eventId) || []; + event.likeCount = this.getLikesCount(eventId); + event.likers = this.likesMap.get(eventId) || []; + event.zapCount = this.getZapsCount(eventId); + event.zappers = this.zapsMap.get(eventId) || []; + event.repostCount = this.getRepostsCount(eventId); + event.reposters = this.repostsMap.get(eventId) || []; + } + return event; + }); + this.eventsSubject.next(updatedEvents); + } + + private async fetchReplies(eventId: string): Promise { + const replyFilter: Filter = { + '#e': [eventId], + kinds: [1], + }; + + const events = await this.fetchFilteredEvents(replyFilter); + + const uniqueEventMap = new Map(); + events.forEach((event) => { + if (!uniqueEventMap.has(event.id)) { + uniqueEventMap.set(event.id, event); + } + }); + + return Promise.all( + Array.from(uniqueEventMap.values()).map((event) => + this.createNewEvent(event) + ) + ); + } + + private async getLikers(eventId: string): Promise { + const likeFilter: Filter = { + '#e': [eventId], + kinds: [7], + }; + + const likeEvents = await this.fetchFilteredEvents(likeFilter); + return likeEvents.map((event) => event.pubkey); + } + + private async getZappers(eventId: string): Promise { + const zapFilter: Filter = { + '#e': [eventId], + kinds: [9735], + }; + + const zapEvents = await this.fetchFilteredEvents(zapFilter); + return zapEvents.map((event) => event.pubkey); + } + + private async getReposters(eventId: string): Promise { + const repostFilter: Filter = { + '#e': [eventId], + kinds: [6], + }; + + const repostEvents = await this.fetchFilteredEvents(repostFilter); + return repostEvents.map((event) => event.pubkey); + } + + getRepliesCount(eventId: string): number { + return (this.repliesMap.get(eventId) || []).length; + } + + getLikesCount(eventId: string): number { + return (this.likesMap.get(eventId) || []).length; + } + + getZapsCount(eventId: string): number { + return (this.zapsMap.get(eventId) || []).length; + } + + getRepostsCount(eventId: string): number { + return (this.repostsMap.get(eventId) || []).length; + } + + hasUserLiked(eventId: string): boolean { + return this.hasLikedMap.get(eventId) || false; + } + + hasUserReposted(eventId: string): boolean { + return this.hasRepostedMap.get(eventId) || false; + } + + getEventStream(): Observable { + return this.eventsSubject.asObservable().pipe(throttleTime(1000)); + } + + hasMoreEvents(): Observable { + return this.noMoreEvents.asObservable(); + } + + async sendTextEvent(content: string): Promise { + if (!content) return; + + try { + const tags: string[][] = []; + + const unsignedEvent = this.signerService.getUnsignedEvent( + 1, + tags, + content + ); + let signedEvent: NostrEvent; + + if (this.signerService.isUsingSecretKey()) { + const privateKey = + await this.signerService.getDecryptedSecretKey(); + const privateKeyBytes = hexToBytes(privateKey); + signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + } else { + signedEvent = + await this.signerService.signEventWithExtension( + unsignedEvent + ); + } + + await this.relayService.publishEventToWriteRelays(signedEvent); + } catch (error) { + console.error('Failed to send text event:', error); + } + } + + async sendLikeEvent(event: NewEvent): Promise { + if (!event) return; + + try { + const tags = [ + ['e', event.id], + ['p', event.pubkey], + ]; + const content = '+'; + + const unsignedEvent = this.signerService.getUnsignedEvent( + 7, + tags, + content + ); + let signedEvent: NostrEvent; + + if (this.signerService.isUsingSecretKey()) { + const privateKey = + await this.signerService.getDecryptedSecretKey(); + const privateKeyBytes = hexToBytes(privateKey); + signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + } else { + signedEvent = + await this.signerService.signEventWithExtension( + unsignedEvent + ); + } + + await this.relayService.publishEventToWriteRelays(signedEvent); + + this.likesMap.set(event.id, [ + ...(this.likesMap.get(event.id) || []), + this.signerService.getPublicKey(), + ]); + this.hasLikedMap.set(event.id, true); + } catch (error) { + console.error('Failed to send like event:', error); + } + } + + async sendZapEvent(event: NewEvent, zapAmount: number): Promise { + if (!event || zapAmount <= 0) return; + + try { + const tags = [ + ['e', event.id], + ['p', event.pubkey], + ['amount', zapAmount.toString()], + ]; + const content = `Zapped with ${zapAmount} sats`; + + const unsignedEvent = this.signerService.getUnsignedEvent( + 9735, + tags, + content + ); + let signedEvent: NostrEvent; + + if (this.signerService.isUsingSecretKey()) { + const privateKey = + await this.signerService.getDecryptedSecretKey(); + const privateKeyBytes = hexToBytes(privateKey); + signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + } else { + signedEvent = + await this.signerService.signEventWithExtension( + unsignedEvent + ); + } + + await this.relayService.publishEventToWriteRelays(signedEvent); + } catch (error) { + console.error('Failed to send zap event:', error); + } + } + + async sendReplyEvent( + parentEvent: NewEvent, + replyContent: string + ): Promise { + if (!parentEvent) return; + + try { + const tags = [ + ['e', parentEvent.id], + ['p', parentEvent.pubkey], + ]; + + const unsignedEvent = this.signerService.getUnsignedEvent( + 1, + tags, + replyContent + ); + let signedEvent: NostrEvent; + + if (this.signerService.isUsingSecretKey()) { + const privateKey = + await this.signerService.getDecryptedSecretKey(); + const privateKeyBytes = hexToBytes(privateKey); + signedEvent = finalizeEvent(unsignedEvent, privateKeyBytes); + } else { + signedEvent = + await this.signerService.signEventWithExtension( + unsignedEvent + ); + } + + await this.relayService.publishEventToWriteRelays(signedEvent); + } catch (error) { + console.error('Failed to send reply event:', error); + } + } - constructor() { } + clearEvents(): void { + this.eventsSubject.next([]); + this.seenEventIds.clear(); + this.lastLoadedEventTime = null; + this.noMoreEvents.next(false); + } } diff --git a/src/app/services/gif.service.ts b/src/app/services/gif.service.ts index 5b563034..0d9c62bd 100644 --- a/src/app/services/gif.service.ts +++ b/src/app/services/gif.service.ts @@ -1,20 +1,19 @@ -import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; import { TenorResponse } from 'app/types/gif'; +import { Observable } from 'rxjs'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class GifService { - - constructor( - private http: HttpClient - ) { } + constructor(private http: HttpClient) {} getTopGifs(search: string, apiKey: string): Observable { - const url = "https://g.tenor.com/v1/search"; - const params = new HttpParams().append('key', apiKey).append('q', search); + const url = 'https://g.tenor.com/v1/search'; + const params = new HttpParams() + .append('key', apiKey) + .append('q', search); return this.http.get(url, { params }); } } diff --git a/src/app/services/hash.service.ts b/src/app/services/hash.service.ts index fbc47d09..8dc4fc41 100644 --- a/src/app/services/hash.service.ts +++ b/src/app/services/hash.service.ts @@ -1,42 +1,44 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { catchError, map } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; import { Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class HashService { - private timestamp: string | null = null; - private readonly ngswFilePath = '/ngsw.json'; + private timestamp: string | null = null; + private readonly ngswFilePath = '/ngsw.json'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) {} - async load(): Promise { - try { - const response = await this.http.get<{ timestamp: string }>(this.ngswFilePath).toPromise(); - if (response && response.timestamp) { - this.timestamp = response.timestamp; - console.log('Timestamp successfully loaded:', this.timestamp); - } else { - console.error('Invalid data format in ngsw.json'); - } - } catch (error) { - console.error('Error loading ngsw.json:', error); + async load(): Promise { + try { + const response = await this.http + .get<{ timestamp: string }>(this.ngswFilePath) + .toPromise(); + if (response && response.timestamp) { + this.timestamp = response.timestamp; + console.log('Timestamp successfully loaded:', this.timestamp); + } else { + console.error('Invalid data format in ngsw.json'); + } + } catch (error) { + console.error('Error loading ngsw.json:', error); + } } - } - getTimestamp(): string | null { - return this.timestamp; - } + getTimestamp(): string | null { + return this.timestamp; + } - loadHash(): Observable { - return this.http.get<{ timestamp: string }>(this.ngswFilePath).pipe( - map((data) => data.timestamp || null), - catchError((error) => { - console.error('Error fetching ngsw.json hash:', error); - return throwError(() => new Error('Failed to load hash.')); - }) - ); - } + loadHash(): Observable { + return this.http.get<{ timestamp: string }>(this.ngswFilePath).pipe( + map((data) => data.timestamp || null), + catchError((error) => { + console.error('Error fetching ngsw.json hash:', error); + return throwError(() => new Error('Failed to load hash.')); + }) + ); + } } diff --git a/src/app/services/image-service.service.ts b/src/app/services/image-service.service.ts index 79d7c8b8..c8cc2236 100644 --- a/src/app/services/image-service.service.ts +++ b/src/app/services/image-service.service.ts @@ -1,20 +1,19 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ImageServiceService { - - constructor(private http: HttpClient) { } + constructor(private http: HttpClient) {} uploadImage(file: File | Blob): Observable { // nostrimg // returns url of uploaded image - let url = "https://nostrimg.com/api/upload"; + let url = 'https://nostrimg.com/api/upload'; const fd = new FormData(); - fd.append("image", file); + fd.append('image', file); // fd.append("submit", "Upload Image"); return this.http.post(url, fd); } @@ -23,9 +22,9 @@ export class ImageServiceService { // not allowed accept from other clients not this uploadImageNostrBuild(file: File | Blob): Observable { // returns url of uploaded image - let url = "https://nostrimg.com/api/upload"; + let url = 'https://nostrimg.com/api/upload'; const fd = new FormData(); - fd.append("fileToUpload", file); + fd.append('fileToUpload', file); // fd.append("submit", "Upload Image"); return this.http.post(url, fd); } diff --git a/src/app/services/indexed-db.service.ts b/src/app/services/indexed-db.service.ts index a9675530..3bf8e1ee 100644 --- a/src/app/services/indexed-db.service.ts +++ b/src/app/services/indexed-db.service.ts @@ -2,242 +2,273 @@ import { Injectable } from '@angular/core'; import localForage from 'localforage'; import { BehaviorSubject, Observable } from 'rxjs'; import { Project, ProjectStats } from './projects.service'; -import { Chat } from 'app/components/chat/chat.types'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class IndexedDBService { - private metadataSubject = new BehaviorSubject(null); - private projectsSubject = new BehaviorSubject([]); - private projectStatsSubject = new BehaviorSubject<{ [key: string]: ProjectStats }>({}); - - private userStore: LocalForage; - private projectsStore: LocalForage; - private projectStatsStore: LocalForage; - - - constructor() { - - this.userStore = localForage.createInstance({ - driver: localForage.INDEXEDDB, - name: 'angor-hub', - version: 1.0, - storeName: 'users', - description: 'Store for user metadata', - }); - - this.projectsStore = localForage.createInstance({ - driver: localForage.INDEXEDDB, - name: 'angor-hub', - version: 1.0, - storeName: 'projects', - description: 'Store for projects', - }); - - this.projectStatsStore = localForage.createInstance({ - driver: localForage.INDEXEDDB, - name: 'angor-hub', - version: 1.0, - storeName: 'projectStats', - description: 'Store for project statistics', - }); - - - this.loadAllProjectsFromDB(); - this.loadAllProjectStatsFromDB(); - } - - getProjectsObservable(): Observable { - return this.projectsSubject.asObservable(); - } - - async saveProject(project: Project): Promise { - try { - await this.projectsStore.setItem(project.projectIdentifier, project); - const updatedProjects = await this.getAllProjects(); - this.projectsSubject.next(updatedProjects); - } catch (error) { - console.error(`Error saving project ${project.projectIdentifier} to IndexedDB:`, error); - } - } - - async getProject(projectIdentifier: string): Promise { - try { - const project = await this.projectsStore.getItem(projectIdentifier); - return project || null; - } catch (error) { - console.error(`Error getting project ${projectIdentifier} from IndexedDB:`, error); - return null; - } - } - - async getAllProjects(): Promise { - try { - const projects: Project[] = []; - await this.projectsStore.iterate((value) => { - projects.push(value); - }); - return projects; - } catch (error) { - console.error('Error getting all projects from IndexedDB:', error); - return []; - } - } - - getProjectStatsObservable(): Observable<{ [key: string]: ProjectStats }> { - return this.projectStatsSubject.asObservable(); - } - - async saveProjectStats(projectIdentifier: string, stats: ProjectStats): Promise { - try { - await this.projectStatsStore.setItem(projectIdentifier, stats); - const updatedStats = await this.getAllProjectStats(); - this.projectStatsSubject.next(updatedStats); - } catch (error) { - console.error(`Error saving project stats for ${projectIdentifier} to IndexedDB:`, error); - } - } - - async getProjectStats(projectIdentifier: string): Promise { - try { - const stats = await this.projectStatsStore.getItem(projectIdentifier); - return stats || null; - } catch (error) { - console.error(`Error getting project stats for ${projectIdentifier} from IndexedDB:`, error); - return null; - } - } - - getMetadataStream(): Observable { - return this.metadataSubject.asObservable(); - } - - async getUserMetadata(pubkey: string): Promise { - try { - const metadata = await this.userStore.getItem(pubkey); - return metadata; - } catch (error) { - console.error('Error getting metadata from IndexedDB:', error); - return null; - } - } - - async saveUserMetadata(pubKey: string, metadata: any): Promise { - try { - metadata.pubKey=pubKey; - await this.userStore.setItem(pubKey, metadata); - this.metadataSubject.next({ pubKey, metadata }); - } catch (error) { - console.error('Error saving metadata to IndexedDB:', error); - } - } - - async removeUserMetadata(pubkey: string): Promise { - try { - await this.userStore.removeItem(pubkey); - this.metadataSubject.next({ pubkey, metadata: null }); - } catch (error) { - console.error('Error removing metadata from IndexedDB:', error); - } - } - - private async loadAllProjectsFromDB(): Promise { - try { - const projects = await this.getAllProjects(); - this.projectsSubject.next(projects); - } catch (error) { - console.error('Error loading projects from IndexedDB:', error); - } - } - - async getAllProjectStats(): Promise<{ [key: string]: ProjectStats }> { - try { - const statsMap: { [key: string]: ProjectStats } = {}; - await this.projectStatsStore.iterate((value, key) => { - statsMap[key] = value; - }); - return statsMap; - } catch (error) { - console.error('Error getting all project stats from IndexedDB:', error); - return {}; - } - } - - private async loadAllProjectStatsFromDB(): Promise { - try { - const stats = await this.getAllProjectStats(); - this.projectStatsSubject.next(stats); - } catch (error) { - console.error('Error loading project stats from IndexedDB:', error); - } - } - - async getAllUsers(): Promise { - try { - const users: any[] = []; - await this.userStore.iterate((value) => { - users.push(value); - }); - return users; - } catch (error) { - console.error('Error getting all users from IndexedDB:', error); - return []; - } - } - - async getSuggestionUsers(): Promise<{ pubkey: string, metadata: any }[]> { - try { - const users: { pubkey: string, metadata: any }[] = []; - await this.userStore.iterate((metadata, pubkey) => { - users.push({ pubkey, metadata }); - }); - - const count = Math.min(users.length, 16); - - const randomUsers = this.getRandomItems(users, count); - return randomUsers; - } catch (error) { - console.error('Error getting suggestion users from IndexedDB:', error); - return []; - } - } - - private getRandomItems(array: T[], count: number): T[] { - const shuffled = array.sort(() => 0.5 - Math.random()); - - return shuffled.slice(0, count); + private metadataSubject = new BehaviorSubject(null); + private projectsSubject = new BehaviorSubject([]); + private projectStatsSubject = new BehaviorSubject<{ + [key: string]: ProjectStats; + }>({}); + + private userStore: LocalForage; + private projectsStore: LocalForage; + private projectStatsStore: LocalForage; + + constructor() { + this.userStore = localForage.createInstance({ + driver: localForage.INDEXEDDB, + name: 'angor-hub', + version: 1.0, + storeName: 'users', + description: 'Store for user metadata', + }); + + this.projectsStore = localForage.createInstance({ + driver: localForage.INDEXEDDB, + name: 'angor-hub', + version: 1.0, + storeName: 'projects', + description: 'Store for projects', + }); + + this.projectStatsStore = localForage.createInstance({ + driver: localForage.INDEXEDDB, + name: 'angor-hub', + version: 1.0, + storeName: 'projectStats', + description: 'Store for project statistics', + }); + + this.loadAllProjectsFromDB(); + this.loadAllProjectStatsFromDB(); + } -} + getProjectsObservable(): Observable { + return this.projectsSubject.asObservable(); + } + + async saveProject(project: Project): Promise { + try { + await this.projectsStore.setItem( + project.projectIdentifier, + project + ); + const updatedProjects = await this.getAllProjects(); + this.projectsSubject.next(updatedProjects); + } catch (error) { + console.error( + `Error saving project ${project.projectIdentifier} to IndexedDB:`, + error + ); + } + } + + async getProject(projectIdentifier: string): Promise { + try { + const project = + await this.projectsStore.getItem(projectIdentifier); + return project || null; + } catch (error) { + console.error( + `Error getting project ${projectIdentifier} from IndexedDB:`, + error + ); + return null; + } + } + + async getAllProjects(): Promise { + try { + const projects: Project[] = []; + await this.projectsStore.iterate((value) => { + projects.push(value); + }); + return projects; + } catch (error) { + console.error('Error getting all projects from IndexedDB:', error); + return []; + } + } + + getProjectStatsObservable(): Observable<{ [key: string]: ProjectStats }> { + return this.projectStatsSubject.asObservable(); + } + + async saveProjectStats( + projectIdentifier: string, + stats: ProjectStats + ): Promise { + try { + await this.projectStatsStore.setItem(projectIdentifier, stats); + const updatedStats = await this.getAllProjectStats(); + this.projectStatsSubject.next(updatedStats); + } catch (error) { + console.error( + `Error saving project stats for ${projectIdentifier} to IndexedDB:`, + error + ); + } + } + + async getProjectStats( + projectIdentifier: string + ): Promise { + try { + const stats = + await this.projectStatsStore.getItem( + projectIdentifier + ); + return stats || null; + } catch (error) { + console.error( + `Error getting project stats for ${projectIdentifier} from IndexedDB:`, + error + ); + return null; + } + } + + getMetadataStream(): Observable { + return this.metadataSubject.asObservable(); + } + + async getUserMetadata(pubkey: string): Promise { + try { + const metadata = await this.userStore.getItem(pubkey); + return metadata; + } catch (error) { + console.error('Error getting metadata from IndexedDB:', error); + return null; + } + } + async saveUserMetadata(pubKey: string, metadata: any): Promise { + try { + metadata.pubKey = pubKey; + await this.userStore.setItem(pubKey, metadata); + this.metadataSubject.next({ pubKey, metadata }); + } catch (error) { + console.error('Error saving metadata to IndexedDB:', error); + } + } - async clearAllMetadata(): Promise { - try { - await this.userStore.clear(); - this.metadataSubject.next(null); - } catch (error) { - console.error('Error clearing all metadata:', error); + async removeUserMetadata(pubkey: string): Promise { + try { + await this.userStore.removeItem(pubkey); + this.metadataSubject.next({ pubkey, metadata: null }); + } catch (error) { + console.error('Error removing metadata from IndexedDB:', error); + } } - } + private async loadAllProjectsFromDB(): Promise { + try { + const projects = await this.getAllProjects(); + this.projectsSubject.next(projects); + } catch (error) { + console.error('Error loading projects from IndexedDB:', error); + } + } - async searchUsersByMetadata(query: string): Promise<{ pubkey: string, user: any }[]> { - try { - const matchingUsers: { pubkey: string, user: any }[] = []; - const searchQuery = query.toLowerCase(); + async getAllProjectStats(): Promise<{ [key: string]: ProjectStats }> { + try { + const statsMap: { [key: string]: ProjectStats } = {}; + await this.projectStatsStore.iterate( + (value, key) => { + statsMap[key] = value; + } + ); + return statsMap; + } catch (error) { + console.error( + 'Error getting all project stats from IndexedDB:', + error + ); + return {}; + } + } - await this.userStore.iterate((user, pubkey) => { - const userString = JSON.stringify(user).toLowerCase(); - if (userString.includes(searchQuery)) { - matchingUsers.push({ pubkey, user }); + private async loadAllProjectStatsFromDB(): Promise { + try { + const stats = await this.getAllProjectStats(); + this.projectStatsSubject.next(stats); + } catch (error) { + console.error('Error loading project stats from IndexedDB:', error); } - }); + } - return matchingUsers; - } catch (error) { - console.error('Error searching users by metadata from IndexedDB:', error); - return []; + async getAllUsers(): Promise { + try { + const users: any[] = []; + await this.userStore.iterate((value) => { + users.push(value); + }); + return users; + } catch (error) { + console.error('Error getting all users from IndexedDB:', error); + return []; + } } - } - } + async getSuggestionUsers(): Promise<{ pubkey: string; metadata: any }[]> { + try { + const users: { pubkey: string; metadata: any }[] = []; + await this.userStore.iterate((metadata, pubkey) => { + users.push({ pubkey, metadata }); + }); + + const count = Math.min(users.length, 16); + + const randomUsers = this.getRandomItems(users, count); + return randomUsers; + } catch (error) { + console.error( + 'Error getting suggestion users from IndexedDB:', + error + ); + return []; + } + } + + private getRandomItems(array: T[], count: number): T[] { + const shuffled = array.sort(() => 0.5 - Math.random()); + + return shuffled.slice(0, count); + } + + async clearAllMetadata(): Promise { + try { + await this.userStore.clear(); + this.metadataSubject.next(null); + } catch (error) { + console.error('Error clearing all metadata:', error); + } + } + + async searchUsersByMetadata( + query: string + ): Promise<{ pubkey: string; user: any }[]> { + try { + const matchingUsers: { pubkey: string; user: any }[] = []; + const searchQuery = query.toLowerCase(); + + await this.userStore.iterate((user, pubkey) => { + const userString = JSON.stringify(user).toLowerCase(); + if (userString.includes(searchQuery)) { + matchingUsers.push({ pubkey, user }); + } + }); + + return matchingUsers; + } catch (error) { + console.error( + 'Error searching users by metadata from IndexedDB:', + error + ); + return []; + } + } +} diff --git a/src/app/services/indexer.service.ts b/src/app/services/indexer.service.ts index ba8f3641..6277acc0 100644 --- a/src/app/services/indexer.service.ts +++ b/src/app/services/indexer.service.ts @@ -1,89 +1,116 @@ import { Injectable } from '@angular/core'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class IndexerService { + private mainnetLocalStorageKey = 'mainnetIndexers'; + private testnetLocalStorageKey = 'testnetIndexers'; + private mainnetPrimaryIndexerKey = 'mainnetPrimaryIndexer'; + private testnetPrimaryIndexerKey = 'testnetPrimaryIndexer'; - private mainnetLocalStorageKey = 'mainnetIndexers'; - private testnetLocalStorageKey = 'testnetIndexers'; - private mainnetPrimaryIndexerKey = 'mainnetPrimaryIndexer'; - private testnetPrimaryIndexerKey = 'testnetPrimaryIndexer'; + private networkStorageKey = 'selectedNetwork'; - private networkStorageKey = 'selectedNetwork'; + private defaultMainnetIndexer = 'https://btc.indexer.angor.io/'; + private defaultTestnetIndexer = 'https://tbtc.indexer.angor.io/'; - private defaultMainnetIndexer = 'https://btc.indexer.angor.io/'; - private defaultTestnetIndexer = 'https://tbtc.indexer.angor.io/'; - - constructor() { - this.initializeDefaultIndexers(); - } + constructor() { + this.initializeDefaultIndexers(); + } - private initializeDefaultIndexers(): void { - if (this.getIndexers('mainnet').length === 0) { - this.addIndexer(this.defaultMainnetIndexer, 'mainnet'); - this.setPrimaryIndexer(this.defaultMainnetIndexer, 'mainnet'); + private initializeDefaultIndexers(): void { + if (this.getIndexers('mainnet').length === 0) { + this.addIndexer(this.defaultMainnetIndexer, 'mainnet'); + this.setPrimaryIndexer(this.defaultMainnetIndexer, 'mainnet'); + } + if (this.getIndexers('testnet').length === 0) { + this.addIndexer(this.defaultTestnetIndexer, 'testnet'); + this.setPrimaryIndexer(this.defaultTestnetIndexer, 'testnet'); + } } - if (this.getIndexers('testnet').length === 0) { - this.addIndexer(this.defaultTestnetIndexer, 'testnet'); - this.setPrimaryIndexer(this.defaultTestnetIndexer, 'testnet'); + + addIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { + let indexers = this.getIndexers(network); + if (!indexers.includes(indexer)) { + indexers.push(indexer); + this.saveIndexers(indexers, network); + } } - } - addIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { - let indexers = this.getIndexers(network); - if (!indexers.includes(indexer)) { - indexers.push(indexer); - this.saveIndexers(indexers, network); + getIndexers(network: 'mainnet' | 'testnet'): string[] { + const storageKey = + network === 'mainnet' + ? this.mainnetLocalStorageKey + : this.testnetLocalStorageKey; + return JSON.parse(localStorage.getItem(storageKey) || '[]'); } - } - getIndexers(network: 'mainnet' | 'testnet'): string[] { - const storageKey = network === 'mainnet' ? this.mainnetLocalStorageKey : this.testnetLocalStorageKey; - return JSON.parse(localStorage.getItem(storageKey) || '[]'); - } + private saveIndexers( + indexers: string[], + network: 'mainnet' | 'testnet' + ): void { + const storageKey = + network === 'mainnet' + ? this.mainnetLocalStorageKey + : this.testnetLocalStorageKey; + localStorage.setItem(storageKey, JSON.stringify(indexers)); + } - private saveIndexers(indexers: string[], network: 'mainnet' | 'testnet'): void { - const storageKey = network === 'mainnet' ? this.mainnetLocalStorageKey : this.testnetLocalStorageKey; - localStorage.setItem(storageKey, JSON.stringify(indexers)); - } + setPrimaryIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { + if (this.getIndexers(network).includes(indexer)) { + const primaryKey = + network === 'mainnet' + ? this.mainnetPrimaryIndexerKey + : this.testnetPrimaryIndexerKey; + localStorage.setItem(primaryKey, indexer); + } + } - setPrimaryIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { - if (this.getIndexers(network).includes(indexer)) { - const primaryKey = network === 'mainnet' ? this.mainnetPrimaryIndexerKey : this.testnetPrimaryIndexerKey; - localStorage.setItem(primaryKey, indexer); + getPrimaryIndexer(network: 'mainnet' | 'testnet'): string | null { + const primaryKey = + network === 'mainnet' + ? this.mainnetPrimaryIndexerKey + : this.testnetPrimaryIndexerKey; + return localStorage.getItem(primaryKey); } - } - getPrimaryIndexer(network: 'mainnet' | 'testnet'): string | null { - const primaryKey = network === 'mainnet' ? this.mainnetPrimaryIndexerKey : this.testnetPrimaryIndexerKey; - return localStorage.getItem(primaryKey); - } + removeIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { + let indexers = this.getIndexers(network); + const index = indexers.indexOf(indexer); + if (index !== -1) { + indexers.splice(index, 1); + this.saveIndexers(indexers, network); + if (indexer === this.getPrimaryIndexer(network)) { + const primaryKey = + network === 'mainnet' + ? this.mainnetPrimaryIndexerKey + : this.testnetPrimaryIndexerKey; + localStorage.removeItem(primaryKey); + } + } + } - removeIndexer(indexer: string, network: 'mainnet' | 'testnet'): void { - let indexers = this.getIndexers(network); - const index = indexers.indexOf(indexer); - if (index !== -1) { - indexers.splice(index, 1); - this.saveIndexers(indexers, network); - if (indexer === this.getPrimaryIndexer(network)) { - const primaryKey = network === 'mainnet' ? this.mainnetPrimaryIndexerKey : this.testnetPrimaryIndexerKey; + clearAllIndexers(network: 'mainnet' | 'testnet'): void { + const storageKey = + network === 'mainnet' + ? this.mainnetLocalStorageKey + : this.testnetLocalStorageKey; + const primaryKey = + network === 'mainnet' + ? this.mainnetPrimaryIndexerKey + : this.testnetPrimaryIndexerKey; + localStorage.removeItem(storageKey); localStorage.removeItem(primaryKey); - } } - } - - clearAllIndexers(network: 'mainnet' | 'testnet'): void { - const storageKey = network === 'mainnet' ? this.mainnetLocalStorageKey : this.testnetLocalStorageKey; - const primaryKey = network === 'mainnet' ? this.mainnetPrimaryIndexerKey : this.testnetPrimaryIndexerKey; - localStorage.removeItem(storageKey); - localStorage.removeItem(primaryKey); - } - setNetwork(network: 'mainnet' | 'testnet'): void { - localStorage.setItem(this.networkStorageKey, network); - } + setNetwork(network: 'mainnet' | 'testnet'): void { + localStorage.setItem(this.networkStorageKey, network); + } - getNetwork(): 'mainnet' | 'testnet' { - return (localStorage.getItem(this.networkStorageKey) as 'mainnet' | 'testnet') || 'testnet'; - } + getNetwork(): 'mainnet' | 'testnet' { + return ( + (localStorage.getItem(this.networkStorageKey) as + | 'mainnet' + | 'testnet') || 'testnet' + ); + } } diff --git a/src/app/services/lightning.service.ts b/src/app/services/lightning.service.ts index 9b6aa348..bf0d8b1d 100644 --- a/src/app/services/lightning.service.ts +++ b/src/app/services/lightning.service.ts @@ -1,102 +1,105 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { LightningInvoice, LightningResponse } from 'app/types/post'; +import { Event } from 'nostr-tools'; import { Observable, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { Event } from 'nostr-tools'; -import { LightningInvoice, LightningResponse } from 'app/types/post'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class LightningService { - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) {} - getLightning(url: string): Observable { - return this.http.get(url).pipe( - catchError((error) => { - console.error('Failed to fetch Lightning response:', error); - return of({ status: 'Failed' } as LightningResponse); - }) - ); - } + getLightning(url: string): Observable { + return this.http.get(url).pipe( + catchError((error) => { + console.error('Failed to fetch Lightning response:', error); + return of({ status: 'Failed' } as LightningResponse); + }) + ); + } - getLightningInvoice(url: string, amount: string): Observable { - const requestUrl = `${url}?amount=${amount}`; - return this.http.get(requestUrl).pipe( - catchError((error) => { - console.error('Failed to fetch Lightning invoice:', error); - return of({ pr: '', status: 'Failed' } as LightningInvoice); - }) - ); - } + getLightningInvoice( + url: string, + amount: string + ): Observable { + const requestUrl = `${url}?amount=${amount}`; + return this.http.get(requestUrl).pipe( + catchError((error) => { + console.error('Failed to fetch Lightning invoice:', error); + return of({ pr: '', status: 'Failed' } as LightningInvoice); + }) + ); + } - getLightningAddress(url: string): string { - try { - const [username, domain] = url.split('@'); - return `https://${domain}/.well-known/lnurlp/${username}`; - } catch (error) { - console.error('Invalid Lightning address format:', url); - return ''; + getLightningAddress(url: string): string { + try { + const [username, domain] = url.split('@'); + return `https://${domain}/.well-known/lnurlp/${username}`; + } catch (error) { + console.error('Invalid Lightning address format:', url); + return ''; + } } - } - sendZapRequest( - callback: string, - zapRequest: Event, - amount: string, - lnurl: string - ): Observable { - const event = encodeURIComponent(JSON.stringify(zapRequest)); - const requestUrl = `${callback}?amount=${amount}&nostr=${event}&lnurl=${lnurl}`; - return this.http.get(requestUrl).pipe( - catchError((error) => { - console.error('Failed to send zap request:', error); - return of({ pr: '', status: 'Failed' } as LightningInvoice); - }) - ); - } + sendZapRequest( + callback: string, + zapRequest: Event, + amount: string, + lnurl: string + ): Observable { + const event = encodeURIComponent(JSON.stringify(zapRequest)); + const requestUrl = `${callback}?amount=${amount}&nostr=${event}&lnurl=${lnurl}`; + return this.http.get(requestUrl).pipe( + catchError((error) => { + console.error('Failed to send zap request:', error); + return of({ pr: '', status: 'Failed' } as LightningInvoice); + }) + ); + } - async login(): Promise { - try { - if (window.webln && !window.webln.isEnabled()) { - await window.webln.enable(); - } - return true; - } catch (error) { - console.error('WebLN login failed:', error); - return false; + async login(): Promise { + try { + if (window.webln && !window.webln.isEnabled()) { + await window.webln.enable(); + } + return true; + } catch (error) { + console.error('WebLN login failed:', error); + return false; + } } - } - hasWebln(): boolean { - return Boolean(window.webln); - } + hasWebln(): boolean { + return Boolean(window.webln); + } - async sendPayment(pr: string): Promise { - try { - if (this.hasWebln()) { - return await window.webln.sendPayment(pr); - } - console.error('WebLN is not available'); - return null; - } catch (error) { - console.error('Payment failed:', error); - throw error; + async sendPayment(pr: string): Promise { + try { + if (this.hasWebln()) { + return await window.webln.sendPayment(pr); + } + console.error('WebLN is not available'); + return null; + } catch (error) { + console.error('Payment failed:', error); + throw error; + } } - } - async payInvoice(pr: string): Promise { - const loggedIn = await this.login(); - if (loggedIn && this.hasWebln()) { - try { - const response = await this.sendPayment(pr); - return Boolean(response); - } catch (error) { - console.error('Failed to pay invoice:', error); + async payInvoice(pr: string): Promise { + const loggedIn = await this.login(); + if (loggedIn && this.hasWebln()) { + try { + const response = await this.sendPayment(pr); + return Boolean(response); + } catch (error) { + console.error('Failed to pay invoice:', error); + return false; + } + } + console.error('WebLN not available or login failed'); return false; - } } - console.error('WebLN not available or login failed'); - return false; - } } diff --git a/src/app/services/link-preview.service.ts b/src/app/services/link-preview.service.ts index 6ebf0b4c..88724ce7 100644 --- a/src/app/services/link-preview.service.ts +++ b/src/app/services/link-preview.service.ts @@ -1,25 +1,26 @@ -import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { Preview } from '../types/preview'; import { map } from 'rxjs/operators'; +import { Preview } from '../types/preview'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class LinkPreviewService { - private _accessKey = '5b54e80a65c77848ceaa4630331e8384950e09d392365'; private _apiURL = 'https://api.linkpreview.net/'; constructor(private http: HttpClient) {} fetchLink(url: string): Observable { - console.log('fetching the following link: ', url); - const params = new HttpParams() - .append('key', this._accessKey) - .append('q', url); - - return this.http.get(this._apiURL, {params: params}).pipe(map(value => value as Preview)); + console.log('fetching the following link: ', url); + const params = new HttpParams() + .append('key', this._accessKey) + .append('q', url); + + return this.http + .get(this._apiURL, { params: params }) + .pipe(map((value) => value as Preview)); } } diff --git a/src/app/services/metadata.service.ts b/src/app/services/metadata.service.ts index b8f37095..1f0bb82e 100644 --- a/src/app/services/metadata.service.ts +++ b/src/app/services/metadata.service.ts @@ -1,200 +1,297 @@ import { Injectable } from '@angular/core'; +import { Filter, NostrEvent } from 'nostr-tools'; import { BehaviorSubject, Observable } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; import { IndexedDBService } from './indexed-db.service'; import { RelayService } from './relay.service'; -import { NostrEvent, Filter } from 'nostr-tools'; -import { debounceTime, throttleTime } from 'rxjs/operators'; + +interface MetadataRequest { + pubkey: string; + isUrgent: boolean; + isGroup: boolean; +} @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class MetadataService { - private metadataSubject = new BehaviorSubject(null); - private requestQueue: Set = new Set(); - private isProcessingQueue = false; - private maxRequestsPerBatch = 3; - private requestDelay = 5000; - - constructor( - private indexedDBService: IndexedDBService, - private relayService: RelayService - ) {} - - getMetadataStream(): Observable { - return this.metadataSubject.asObservable().pipe(throttleTime(2000)); - } - - private enqueueRequest(pubkey: string): void { - this.requestQueue.add(pubkey); - this.processQueue(); - } - - async fetchMetadataForMultipleKeys(pubkeys: string[]): Promise { - const filter: Filter = { - kinds: [0], - authors: pubkeys, - }; - - try { - await this.relayService.ensureConnectedRelays(); - const connectedRelays = this.relayService.getConnectedRelays(); - - if (connectedRelays.length === 0) { - console.error('No relays are connected.'); - return []; - } - - const metadataList: any[] = []; - - const sub = this.relayService.getPool().subscribeMany(connectedRelays, [filter], { - onevent: async (event: NostrEvent) => { - if (event.kind === 0) { - try { - const metadata = JSON.parse(event.content); - await this.indexedDBService.saveUserMetadata(event.pubkey, metadata); - metadataList.push({ pubkey: event.pubkey, metadata }); - - } catch (error) { - console.error('Error parsing metadata:', error); - } - } - }, - oneose: () => { + private metadataSubject = new BehaviorSubject(null); + private requestQueue: Set = new Set(); + private isProcessingQueue = false; + private maxRequestsPerBatch = 3; + private requestDelay = 5000; + + constructor( + private indexedDBService: IndexedDBService, + private relayService: RelayService + ) {} + + getMetadataStream(): Observable { + return this.metadataSubject.asObservable().pipe(throttleTime(2000)); + } + + enqueueRequest(pubkey: string, isUrgent: boolean = false, isGroup: boolean = false): void { + const request: MetadataRequest = { pubkey, isUrgent, isGroup }; + if (isUrgent) { + + this.requestQueue = new Set([request, ...Array.from(this.requestQueue)]); + } else { + this.requestQueue.add(request); } - }); + this.processQueue(); + } - setTimeout(() => { - sub.close(); - }, 1000 ); + private async processQueue(): Promise { + if (this.isProcessingQueue || this.requestQueue.size === 0) { + return; + } - return metadataList; - } catch (error) { - console.error('Failed to fetch metadata for multiple keys:', error); - return []; - } - } + this.isProcessingQueue = true; + + while (this.requestQueue.size > 0) { + + const urgentRequests = Array.from(this.requestQueue).filter(req => req.isUrgent); + const batch = urgentRequests.length > 0 + ? urgentRequests.slice(0, this.maxRequestsPerBatch) + : Array.from(this.requestQueue).slice(0, this.maxRequestsPerBatch); + + this.requestQueue = new Set( + Array.from(this.requestQueue).slice(this.maxRequestsPerBatch) + ); + + await Promise.all( + batch.map(async (request) => { + try { + const updatedMetadata = request.isGroup + ? await this.fetchMetadataForMultipleKeys([request.pubkey]) + : await this.fetchMetadataRealtime(request.pubkey); + if (updatedMetadata) { + await this.indexedDBService.saveUserMetadata(request.pubkey, updatedMetadata); + this.metadataSubject.next(updatedMetadata); + } + } catch (error) { + console.error(`Failed to update metadata for user: ${request.pubkey}`, error); + } + }) + ); + + await new Promise((resolve) => setTimeout(resolve, this.requestDelay)); + } - private async processQueue(): Promise { - if (this.isProcessingQueue || this.requestQueue.size === 0) { - return; + this.isProcessingQueue = false; } - this.isProcessingQueue = true; - - while (this.requestQueue.size > 0) { - const batch = Array.from(this.requestQueue).slice(0, this.maxRequestsPerBatch); - this.requestQueue = new Set(Array.from(this.requestQueue).slice(this.maxRequestsPerBatch)); + async fetchMetadataForMultipleKeys(pubkeys: string[]): Promise { + const filter: Filter = { + kinds: [0], + authors: pubkeys, + }; - await Promise.all(batch.map(async (pubkey) => { try { - const updatedMetadata = await this.fetchMetadataRealtime(pubkey); - if (updatedMetadata) { - await this.indexedDBService.saveUserMetadata(pubkey, updatedMetadata); - this.metadataSubject.next(updatedMetadata); - } + await this.relayService.ensureConnectedRelays(); + const connectedRelays = this.relayService.getConnectedRelays(); + + if (connectedRelays.length === 0) { + console.error('No relays are connected.'); + return []; + } + + const metadataList: any[] = []; + + const sub = this.relayService + .getPool() + .subscribeMany(connectedRelays, [filter], { + onevent: async (event: NostrEvent) => { + if (event.kind === 0) { + try { + const metadata = JSON.parse(event.content); + await this.indexedDBService.saveUserMetadata( + event.pubkey, + metadata + ); + metadataList.push({ + pubkey: event.pubkey, + metadata, + }); + } catch (error) { + console.error('Error parsing metadata:', error); + } + } + }, + oneose: () => {}, + }); + + setTimeout(() => { + sub.close(); + }, 1000); + + return metadataList; } catch (error) { - console.error(`Failed to update metadata for user: ${pubkey}`, error); + console.error('Failed to fetch metadata for multiple keys:', error); + return []; + } + } + + async fetchMetadataWithCache(pubkey: string): Promise { + + const cachedMetadata = await this.indexedDBService.getUserMetadata(pubkey); + + + if (cachedMetadata) { + return cachedMetadata; } - })); - await new Promise(resolve => setTimeout(resolve, this.requestDelay)); + + return new Promise((resolve, reject) => { + + this.enqueueRequest(pubkey); + + + const subscription = this.metadataSubject.asObservable().subscribe({ + next: (updatedMetadata) => { + if (updatedMetadata && updatedMetadata.pubkey === pubkey) { + + resolve(updatedMetadata); + subscription.unsubscribe(); + } + }, + error: (error) => { + console.error('Error fetching metadata:', error); + reject(error); + subscription.unsubscribe(); + } + }); + }); } - this.isProcessingQueue = false; - } - async fetchMetadataWithCache(pubkey: string): Promise { - const metadata = await this.indexedDBService.getUserMetadata(pubkey); - if (metadata) { - this.metadataSubject.next(metadata); - } else { - this.enqueueRequest(pubkey); + private subscribeToMetadataUpdates(pubkey: string): void { + this.relayService.ensureConnectedRelays().then(() => { + const filter: Filter = { authors: [pubkey], kinds: [0] }; + + this.relayService + .getPool() + .subscribeMany( + this.relayService.getConnectedRelays(), + [filter], + { + onevent: async (event: NostrEvent) => { + if (event.pubkey === pubkey && event.kind === 0) { + try { + const updatedMetadata = JSON.parse( + event.content + ); + await this.indexedDBService.saveUserMetadata( + pubkey, + updatedMetadata + ); + this.metadataSubject.next(updatedMetadata); + } catch (error) { + console.error( + 'Error parsing updated metadata:', + error + ); + } + } + }, + oneose() {}, + } + ); + }); } - this.subscribeToMetadataUpdates(pubkey); - return metadata; - } - - private subscribeToMetadataUpdates(pubkey: string): void { - this.relayService.ensureConnectedRelays().then(() => { - const filter: Filter = { authors: [pubkey], kinds: [0] }; - - this.relayService.getPool().subscribeMany(this.relayService.getConnectedRelays(), [filter], { - onevent: async (event: NostrEvent) => { - if (event.pubkey === pubkey && event.kind === 0) { - try { - const updatedMetadata = JSON.parse(event.content); - await this.indexedDBService.saveUserMetadata(pubkey, updatedMetadata); - this.metadataSubject.next(updatedMetadata); - } catch (error) { - console.error('Error parsing updated metadata:', error); - } - } - }, - oneose(){}, - }); - }); - } - - async fetchMetadataRealtime(pubkey: string): Promise { - await this.relayService.ensureConnectedRelays(); - const connectedRelays = this.relayService.getConnectedRelays(); - - if (connectedRelays.length === 0) { - throw new Error('No connected relays'); + async fetchMetadataRealtime(pubkey: string): Promise { + await this.relayService.ensureConnectedRelays(); + const connectedRelays = this.relayService.getConnectedRelays(); + + if (connectedRelays.length === 0) { + throw new Error('No connected relays'); + } + + return new Promise((resolve) => { + const sub = this.relayService + .getPool() + .subscribeMany( + connectedRelays, + [{ authors: [pubkey], kinds: [0] }], + { + onevent: (event: NostrEvent) => { + if (event.pubkey === pubkey && event.kind === 0) { + try { + const content = JSON.parse(event.content); + resolve(content); + } catch (error) { + console.error( + 'Error parsing event content:', + error + ); + resolve(null); + } finally { + sub.close(); + } + } + }, + oneose() { + sub.close(); + resolve(null); + }, + } + ); + }); } - return new Promise((resolve) => { - const sub = this.relayService.getPool().subscribeMany(connectedRelays, [{ authors: [pubkey], kinds: [0] }], { - onevent: (event: NostrEvent) => { - if (event.pubkey === pubkey && event.kind === 0) { - try { - const content = JSON.parse(event.content); - resolve(content); - } catch (error) { - console.error('Error parsing event content:', error); - resolve(null); - } finally { - sub.close(); + async refreshAllStoredMetadata(): Promise { + const storedUsers = await this.indexedDBService.getAllUsers(); + if (!storedUsers || storedUsers.length === 0) { + return; + } + + storedUsers.forEach((user) => this.enqueueRequest(user.pubkey)); + } + + async getUserMetadata(pubkey: string): Promise { + try { + const cachedMetadata = + await this.indexedDBService.getUserMetadata(pubkey); + if (cachedMetadata) { + return cachedMetadata; + } + + const liveMetadata = await this.fetchMetadataRealtime(pubkey); + if (liveMetadata) { + await this.indexedDBService.saveUserMetadata( + pubkey, + liveMetadata + ); + return liveMetadata; } - } - }, - oneose() { - sub.close(); - resolve(null); - }, - }); - }); - } - - async refreshAllStoredMetadata(): Promise { - const storedUsers = await this.indexedDBService.getAllUsers(); - if (!storedUsers || storedUsers.length === 0) { - return; + + return null; + } catch (error) { + console.error(`Error fetching metadata for user ${pubkey}:`, error); + return null; + } } - storedUsers.forEach(user => this.enqueueRequest(user.pubkey)); - } + private async fetchFilteredEvents(filter: Filter): Promise { + await this.relayService.ensureConnectedRelays(); + const connectedRelays = this.relayService.getConnectedRelays(); - async getUserMetadata(pubkey: string): Promise { - try { - const cachedMetadata = await this.indexedDBService.getUserMetadata(pubkey); - if (cachedMetadata) { - return cachedMetadata; - } + const eventMap = new Map(); + const pool = this.relayService.getPool(); - const liveMetadata = await this.fetchMetadataRealtime(pubkey); - if (liveMetadata) { - await this.indexedDBService.saveUserMetadata(pubkey, liveMetadata); - return liveMetadata; - } + await Promise.all( + connectedRelays.map(async (relay) => { + const events = await pool.querySync([relay], filter); + events.forEach((event) => { + if (!eventMap.has(event.id)) { + eventMap.set(event.id, event); + } + }); + }) + ); - return null; - } catch (error) { - console.error(`Error fetching metadata for user ${pubkey}:`, error); - return null; + return Array.from(eventMap.values()); } - } - } diff --git a/src/app/services/nip05.service.ts b/src/app/services/nip05.service.ts index 6c2ee424..d16fe3ab 100644 --- a/src/app/services/nip05.service.ts +++ b/src/app/services/nip05.service.ts @@ -1,23 +1,19 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; import { NIP05 } from '../types/nostr'; -import { Observable, of} from 'rxjs'; -import { HttpClient } from '@angular/common/http'; -import { catchError } from 'rxjs/operators'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class Nip05Service { - - constructor( - private http: HttpClient - ) { } + constructor(private http: HttpClient) {} // url would be something like `brah@npubkey.com` // which needs to be processed to make the actual request getNIP05Url(url: string): string { // https:///.well-known/nostr.json?name= - const splitUrl = url.split("@"); + const splitUrl = url.split('@'); const username = splitUrl[0]; const domain = splitUrl[1]; return `https://${domain}/.well-known/nostr.json?name=${username}`; @@ -25,6 +21,6 @@ export class Nip05Service { getNIP05(nip05: string): Observable { const url = this.getNIP05Url(nip05); - return this.http.get(url) + return this.http.get(url); } } diff --git a/src/app/services/nostr-band-api.service.ts b/src/app/services/nostr-band-api.service.ts index bc293c84..862f502f 100644 --- a/src/app/services/nostr-band-api.service.ts +++ b/src/app/services/nostr-band-api.service.ts @@ -1,38 +1,35 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { withCache } from '@ngneat/cashew'; import { Observable } from 'rxjs'; import { Kind0Content } from '../types/user'; -import { withCache } from '@ngneat/cashew'; interface Profile { pubkey: string; new_followers_count: number; relays: Array; - profile: Kind0Content + profile: Kind0Content; } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class NostrBandApiService { + baseUrl: string = 'https://api.nostr.band/'; - baseUrl: string = "https://api.nostr.band/"; - - trending: string = "v0/trending/"; - profiles: string = "profiles"; - notes: string = "notes"; + trending: string = 'v0/trending/'; + profiles: string = 'profiles'; + notes: string = 'notes'; - constructor( - private http: HttpClient - ) { } + constructor(private http: HttpClient) {} - getTrendingProfiles(): Observable{ + getTrendingProfiles(): Observable { const url = `${this.baseUrl}${this.trending}${this.profiles}`; - return this.http.get(url, {context: withCache()}); + return this.http.get(url, { context: withCache() }); } - getTrendingNotes(): Observable{ + getTrendingNotes(): Observable { const url = `${this.baseUrl}${this.trending}${this.notes}`; - return this.http.get(url, {context: withCache()}); + return this.http.get(url, { context: withCache() }); } } diff --git a/src/app/services/notification.service.ts b/src/app/services/notification.service.ts index 53bf57d0..47654485 100644 --- a/src/app/services/notification.service.ts +++ b/src/app/services/notification.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Filter, NostrEvent } from 'nostr-tools'; -import { Subject, Observable, BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { RelayService } from './relay.service'; export interface NostrNotification { @@ -17,10 +17,9 @@ export interface NostrNotification { } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class NotificationService { - private notificationSubject = new BehaviorSubject([]); private notificationCount = new BehaviorSubject(0); private lastNotificationTimestamp: number | null = null; @@ -40,7 +39,9 @@ export class NotificationService { } private loadTimestampFromLocalStorage(): number | null { - const storedTimestamp = localStorage.getItem('lastNotificationTimestamp'); + const storedTimestamp = localStorage.getItem( + 'lastNotificationTimestamp' + ); return storedTimestamp ? parseInt(storedTimestamp, 10) : null; } @@ -59,10 +60,12 @@ export class NotificationService { private loadFilterPreferences(): number[] { const storedPreferences = localStorage.getItem('notificationSettings'); - return storedPreferences ? JSON.parse(storedPreferences) : [1, 3, 4, 9735]; // Default to all kinds if not set + return storedPreferences + ? JSON.parse(storedPreferences) + : [1, 3, 4, 7, 9735]; // Default to all kinds if not set } - public async subscribeToNotifications(pubkey: string): Promise { + public async subscribeToNotifications(pubkey: string): Promise { await this.relayService.ensureConnectedRelays(); const pool = this.relayService.getPool(); const connectedRelays = this.relayService.getConnectedRelays(); @@ -74,30 +77,28 @@ export class NotificationService { const filterPreferences = this.loadFilterPreferences(); if (filterPreferences.length === 0) { - filterPreferences.push(1, 3, 4, 9735); + filterPreferences.push(1, 3, 4, 7, 9735); } const filter: Filter = { kinds: filterPreferences, '#p': [pubkey], limit: 50, - since: lastNotificationTimestamp || undefined + since: lastNotificationTimestamp || undefined, }; return new Promise((resolve) => { const sub = pool.subscribeMany(connectedRelays, [filter], { - onevent: (event: NostrEvent) => this.handleNotificationEvent(event, pubkey), + onevent: (event: NostrEvent) => + this.handleNotificationEvent(event, pubkey), oneose() { - resolve(); - } + resolve(); + }, }); }); } private handleNotificationEvent(event: NostrEvent, pubkey: string): void { - console.log('Received event:', event); - - if (this.isNotificationEvent(event, pubkey)) { const eventTimestamp = event.created_at * 1000; const formattedDate = new Date(eventTimestamp); @@ -127,6 +128,11 @@ export class NotificationService { notificationDescription = 'You have a new follower.'; notificationIcon = 'heroicons_outline:user-plus'; break; + case 7: + notificationTitle = 'New Like'; + notificationDescription = 'You have a new Like.'; + notificationIcon = 'heroicons_outline:hand-thumb-up'; + break; default: notificationTitle = 'Notification'; notificationIcon = 'heroicons_outline:bell'; @@ -140,21 +146,20 @@ export class NotificationService { description: notificationDescription, time: formattedDate, kind: event.kind, - read: false + read: false, }; - console.log('Generated notification:', notification); - - const currentNotifications = this.notificationSubject.value; - const updatedNotifications = [notification, ...currentNotifications].slice(0, 50); + const updatedNotifications = [ + notification, + ...currentNotifications, + ].slice(0, 50); this.notificationSubject.next(updatedNotifications); this.incrementNotificationCount(event.created_at); } } - private incrementNotificationCount(timestamp: number): void { const newCount = this.notificationCount.value + 1; this.notificationCount.next(newCount); @@ -162,10 +167,12 @@ export class NotificationService { } public markAllAsRead(): void { - const updatedNotifications = this.notificationSubject.value.map(notification => ({ - ...notification, - read: true - })); + const updatedNotifications = this.notificationSubject.value.map( + (notification) => ({ + ...notification, + read: true, + }) + ); this.notificationSubject.next(updatedNotifications); this.notificationCount.next(0); const currentTimestamp = Math.floor(Date.now() / 1000); @@ -173,6 +180,6 @@ export class NotificationService { } private isNotificationEvent(event: NostrEvent, pubkey: string): boolean { - return event.tags.some(tag => tag[0] === 'p' && tag[1] === pubkey); + return event.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey); } } diff --git a/src/app/services/projects.service.ts b/src/app/services/projects.service.ts index 8a249814..e3b1c710 100644 --- a/src/app/services/projects.service.ts +++ b/src/app/services/projects.service.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { of, Observable } from 'rxjs'; -import { IndexerService } from './indexer.service'; import { IndexedDBService } from './indexed-db.service'; +import { IndexerService } from './indexer.service'; export interface Project { founderKey: string; @@ -53,16 +53,21 @@ export class ProjectsService { } this.loading = true; - const indexerUrl = this.indexerService.getPrimaryIndexer(this.selectedNetwork); + const indexerUrl = this.indexerService.getPrimaryIndexer( + this.selectedNetwork + ); const url = this.totalProjectsFetched ? `${indexerUrl}api/query/Angor/projects?offset=${this.offset}&limit=${this.limit}` : `${indexerUrl}api/query/Angor/projects?limit=${this.limit}`; try { - const response = await this.http.get(url, { observe: 'response' }).toPromise(); + const response = await this.http + .get(url, { observe: 'response' }) + .toPromise(); if (!this.totalProjectsFetched && response && response.headers) { - const paginationTotal = response.headers.get('pagination-total'); + const paginationTotal = + response.headers.get('pagination-total'); this.totalProjects = paginationTotal ? +paginationTotal : 0; this.totalProjectsFetched = true; this.offset = Math.max(this.totalProjects - this.limit, 0); @@ -76,9 +81,12 @@ export class ProjectsService { } const uniqueNewProjects = newProjects.filter( - newProject => !this.projects.some( - existingProject => existingProject.projectIdentifier === newProject.projectIdentifier - ) + (newProject) => + !this.projects.some( + (existingProject) => + existingProject.projectIdentifier === + newProject.projectIdentifier + ) ); if (!uniqueNewProjects.length) { @@ -86,22 +94,36 @@ export class ProjectsService { return []; } - const saveProjectsPromises = uniqueNewProjects.map(async project => { - await this.indexedDBService.saveProject(project); - }); - - const projectDetailsPromises = uniqueNewProjects.map(async project => { - try { - const projectStats = await this.indexedDBService.getProjectStats(project.projectIdentifier); - project.totalInvestmentsCount = projectStats?.investorCount ?? 0; - return project; - } catch (error) { - console.error(`Error fetching details for project ${project.projectIdentifier}:`, error); - return project; + const saveProjectsPromises = uniqueNewProjects.map( + async (project) => { + await this.indexedDBService.saveProject(project); + } + ); + + const projectDetailsPromises = uniqueNewProjects.map( + async (project) => { + try { + const projectStats = + await this.indexedDBService.getProjectStats( + project.projectIdentifier + ); + project.totalInvestmentsCount = + projectStats?.investorCount ?? 0; + return project; + } catch (error) { + console.error( + `Error fetching details for project ${project.projectIdentifier}:`, + error + ); + return project; + } } - }); + ); - await Promise.all([...saveProjectsPromises, ...projectDetailsPromises]); + await Promise.all([ + ...saveProjectsPromises, + ...projectDetailsPromises, + ]); this.projects = [...this.projects, ...uniqueNewProjects]; this.offset = Math.max(this.offset - this.limit, 0); @@ -116,49 +138,74 @@ export class ProjectsService { } fetchProjectStats(projectIdentifier: string): Observable { - const indexerUrl = this.indexerService.getPrimaryIndexer(this.selectedNetwork); + const indexerUrl = this.indexerService.getPrimaryIndexer( + this.selectedNetwork + ); const url = `${indexerUrl}api/query/Angor/projects/${projectIdentifier}/stats`; return this.http.get(url).pipe( catchError((error) => { - console.error(`Error fetching stats for project ${projectIdentifier}:`, error); + console.error( + `Error fetching stats for project ${projectIdentifier}:`, + error + ); return of({} as ProjectStats); }) ); } - async fetchAndSaveProjectStats(projectIdentifier: string): Promise { + async fetchAndSaveProjectStats( + projectIdentifier: string + ): Promise { try { - const stats = await this.fetchProjectStats(projectIdentifier).toPromise(); + const stats = + await this.fetchProjectStats(projectIdentifier).toPromise(); if (stats) { - await this.indexedDBService.saveProjectStats(projectIdentifier, stats); + await this.indexedDBService.saveProjectStats( + projectIdentifier, + stats + ); } return stats; } catch (error) { - console.error(`Error fetching and saving stats for project ${projectIdentifier}:`, error); + console.error( + `Error fetching and saving stats for project ${projectIdentifier}:`, + error + ); return null; } } fetchProjectDetails(projectIdentifier: string): Observable { - const indexerUrl = this.indexerService.getPrimaryIndexer(this.selectedNetwork); + const indexerUrl = this.indexerService.getPrimaryIndexer( + this.selectedNetwork + ); const url = `${indexerUrl}api/query/Angor/projects/${projectIdentifier}`; return this.http.get(url).pipe( catchError((error) => { - console.error(`Error fetching details for project ${projectIdentifier}:`, error); + console.error( + `Error fetching details for project ${projectIdentifier}:`, + error + ); return of({} as Project); }) ); } - async fetchAndSaveProjectDetails(projectIdentifier: string): Promise { + async fetchAndSaveProjectDetails( + projectIdentifier: string + ): Promise { try { - const project = await this.fetchProjectDetails(projectIdentifier).toPromise(); + const project = + await this.fetchProjectDetails(projectIdentifier).toPromise(); if (project) { await this.indexedDBService.saveProject(project); } return project; } catch (error) { - console.error(`Error fetching and saving details for project ${projectIdentifier}:`, error); + console.error( + `Error fetching and saving details for project ${projectIdentifier}:`, + error + ); return null; } } @@ -167,7 +214,9 @@ export class ProjectsService { return this.indexedDBService.getAllProjects(); } - async getProjectStatsFromDB(projectIdentifier: string): Promise { + async getProjectStatsFromDB( + projectIdentifier: string + ): Promise { return this.indexedDBService.getProjectStats(projectIdentifier); } diff --git a/src/app/services/relay.service.ts b/src/app/services/relay.service.ts index d1f3cde1..c7351232 100644 --- a/src/app/services/relay.service.ts +++ b/src/app/services/relay.service.ts @@ -1,17 +1,31 @@ -import { Injectable } from "@angular/core"; -import { BehaviorSubject, Observable } from "rxjs"; -import { NostrEvent, SimplePool } from "nostr-tools"; +import { Injectable } from '@angular/core'; +import { NostrEvent, SimplePool } from 'nostr-tools'; +import { BehaviorSubject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root', }) export class RelayService { private pool: SimplePool; - private relays: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }[] = []; + private relays: { + url: string; + connected: boolean; + retries: number; + retryTimeout: any; + accessType: string; + ws?: WebSocket; + }[] = []; private maxRetries = 10; private retryDelay = 15000; private eventSubject = new BehaviorSubject(null); - private relaysSubject = new BehaviorSubject<{ url: string, connected: boolean, accessType: string, ws?: WebSocket }[]>([]); + private relaysSubject = new BehaviorSubject< + { + url: string; + connected: boolean; + accessType: string; + ws?: WebSocket; + }[] + >([]); constructor() { this.pool = new SimplePool(); @@ -21,26 +35,65 @@ export class RelayService { this.relaysSubject.next(this.relays); } - private loadRelaysFromLocalStorage(): { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }[] { - const storedRelays = JSON.parse(localStorage.getItem('nostrRelays') || '[]'); + private loadRelaysFromLocalStorage(): { + url: string; + connected: boolean; + retries: number; + retryTimeout: any; + accessType: string; + ws?: WebSocket; + }[] { + const storedRelays = JSON.parse( + localStorage.getItem('nostrRelays') || '[]' + ); const defaultRelays = [ - { url: 'wss://relay.primal.net', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined }, - { url: 'wss://relay.damus.io', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined }, - { url: 'wss://relay.angor.io', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined }, - { url: 'wss://relay2.angor.io', connected: false, retries: 0, retryTimeout: null, accessType: 'read-write', ws: undefined }, + { + url: 'wss://relay.primal.net', + connected: false, + retries: 0, + retryTimeout: null, + accessType: 'read-write', + ws: undefined, + }, + { + url: 'wss://relay.damus.io', + connected: false, + retries: 0, + retryTimeout: null, + accessType: 'read-write', + ws: undefined, + }, + { + url: 'wss://relay.angor.io', + connected: false, + retries: 0, + retryTimeout: null, + accessType: 'read-write', + ws: undefined, + }, + { + url: 'wss://relay2.angor.io', + connected: false, + retries: 0, + retryTimeout: null, + accessType: 'read-write', + ws: undefined, + }, ]; - return storedRelays.length > 0 ? storedRelays.map(relay => ({ - ...relay, - connected: false, - retries: 0, - retryTimeout: null, - ws: undefined, - })) : defaultRelays; + return storedRelays.length > 0 + ? storedRelays.map((relay) => ({ + ...relay, + connected: false, + retries: 0, + retryTimeout: null, + ws: undefined, + })) + : defaultRelays; } private saveRelaysToLocalStorage(): void { - const relaysToSave = this.relays.map(relay => ({ + const relaysToSave = this.relays.map((relay) => ({ url: relay.url, accessType: relay.accessType, connected: relay.connected, @@ -51,7 +104,14 @@ export class RelayService { this.relaysSubject.next(this.relays); } - private connectToRelay(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }): void { + private connectToRelay(relay: { + url: string; + connected: boolean; + retries: number; + retryTimeout: any; + accessType: string; + ws?: WebSocket; + }): void { if (relay.connected) { return; } @@ -77,7 +137,10 @@ export class RelayService { relay.ws.onmessage = (message) => { try { - const dataStr = typeof message.data === 'string' ? message.data : message.data.toString('utf-8'); + const dataStr = + typeof message.data === 'string' + ? message.data + : message.data.toString('utf-8'); const parsedData = JSON.parse(dataStr); this.eventSubject.next(parsedData); } catch (error) { @@ -86,9 +149,18 @@ export class RelayService { }; } - private handleRelayError(relay: { url: string, connected: boolean, retries: number, retryTimeout: any, accessType: string, ws?: WebSocket }): void { + private handleRelayError(relay: { + url: string; + connected: boolean; + retries: number; + retryTimeout: any; + accessType: string; + ws?: WebSocket; + }): void { if (relay.retries >= this.maxRetries) { - console.error(`Max retries reached for relay: ${relay.url}. No further attempts will be made.`); + console.error( + `Max retries reached for relay: ${relay.url}. No further attempts will be made.` + ); return; } @@ -131,7 +203,7 @@ export class RelayService { }); window.addEventListener('beforeunload', () => { - this.relays.forEach(relay => { + this.relays.forEach((relay) => { if (relay.ws) { relay.ws.close(); } @@ -140,64 +212,127 @@ export class RelayService { } public getConnectedRelays(): string[] { - return this.relays.filter((relay) => relay.connected).map((relay) => relay.url); + return this.relays + .filter((relay) => relay.connected) + .map((relay) => relay.url); } - public getRelays(): Observable<{ url: string, connected: boolean, accessType: string, ws?: WebSocket }[]> { + public getRelays(): Observable< + { + url: string; + connected: boolean; + accessType: string; + ws?: WebSocket; + }[] + > { return this.relaysSubject.asObservable(); } - public publishEventToWriteRelays(event: NostrEvent): void { - const writeRelays = this.relays.filter(relay => relay.accessType === 'write' || relay.accessType === 'read-write'); - writeRelays.forEach((relay) => { - if (relay.connected && relay.ws?.readyState === WebSocket.OPEN) { - relay.ws.send(JSON.stringify(event)); + async publishEventToWriteRelays(event: NostrEvent): Promise { + const pool = this.getPool(); + + const connectedRelays = this.getConnectedRelays(); + console.log('Connected relays:', connectedRelays); + + const writeRelays = this.relays.filter( + (relay) => + relay.accessType === 'write' || + relay.accessType === 'read-write' + ); + console.log('Write relays:', writeRelays); + + const allowedRelays = writeRelays + .map((relay) => relay.url) + .filter((url) => connectedRelays.includes(url)); + + if (allowedRelays.length === 0) { + throw new Error('No connected write relays available'); + } + + console.log('Allowed relays for publishing:', allowedRelays); + + const publishPromises = allowedRelays.map(async (relayUrl) => { + try { + await pool.publish([relayUrl], event); + this.eventSubject.next(event); + return event; + } catch (error) { + console.error( + `Failed to publish event to relay: ${relayUrl}`, + error + ); + throw error; } }); - } + try { + await Promise.any(publishPromises); + return event; + } catch (aggregateError) { + console.error( + 'Failed to publish event: AggregateError', + aggregateError + ); + this.handlePublishFailure(aggregateError); + throw aggregateError; + } + } async publishEventToRelays(event: NostrEvent): Promise { - const pool = this.getPool(); + const pool = this.getPool(); const connectedRelays = this.getConnectedRelays(); if (connectedRelays.length === 0) { - throw new Error('No connected relays'); + throw new Error('No connected relays'); } const publishPromises = connectedRelays.map(async (relayUrl) => { - try { - await pool.publish([relayUrl], event); - this.eventSubject.next(event); // Emit the event to subscribers - return event; - } catch (error) { - console.error(`Failed to publish event to relay: ${relayUrl}`, error); - throw error; - } + try { + await pool.publish([relayUrl], event); + this.eventSubject.next(event); // Emit the event to subscribers + return event; + } catch (error) { + console.error( + `Failed to publish event to relay: ${relayUrl}`, + error + ); + throw error; + } }); try { - await Promise.any(publishPromises); - return event; + await Promise.any(publishPromises); + return event; } catch (aggregateError) { - console.error('Failed to publish event: AggregateError', aggregateError); - this.handlePublishFailure(aggregateError); - throw aggregateError; + console.error( + 'Failed to publish event: AggregateError', + aggregateError + ); + this.handlePublishFailure(aggregateError); + throw aggregateError; } - } + } - private handlePublishFailure(error: unknown): void { + private handlePublishFailure(error: unknown): void { if (error instanceof AggregateError) { - console.error('All relays failed to publish the event. Retrying...'); + console.error( + 'All relays failed to publish the event. Retrying...' + ); } else { - console.error('An unexpected error occurred:', error); + console.error('An unexpected error occurred:', error); } - } - + } public addRelay(url: string, accessType: string = 'read-write'): void { - if (!this.relays.some(relay => relay.url === url)) { - const newRelay = { url, connected: false, retries: 0, retryTimeout: null, accessType, ws: undefined }; + if (!this.relays.some((relay) => relay.url === url)) { + const newRelay = { + url, + connected: false, + retries: 0, + retryTimeout: null, + accessType, + ws: undefined, + }; this.relays.push(newRelay); this.connectToRelay(newRelay); this.saveRelaysToLocalStorage(); @@ -205,18 +340,20 @@ export class RelayService { } public removeRelay(url: string): void { - this.relays = this.relays.filter(relay => relay.url !== url); + this.relays = this.relays.filter((relay) => relay.url !== url); this.saveRelaysToLocalStorage(); } public removeAllCustomRelays(): void { const defaultRelays = ['wss://relay.angor.io', 'wss://relay2.angor.io']; - this.relays = this.relays.filter(relay => defaultRelays.includes(relay.url)); + this.relays = this.relays.filter((relay) => + defaultRelays.includes(relay.url) + ); this.saveRelaysToLocalStorage(); } public updateRelayAccessType(url: string, accessType: string): void { - const relay = this.relays.find(relay => relay.url === url); + const relay = this.relays.find((relay) => relay.url === url); if (relay) { relay.accessType = accessType; this.saveRelaysToLocalStorage(); diff --git a/src/app/services/security.service.ts b/src/app/services/security.service.ts index eddffde4..6e31928c 100644 --- a/src/app/services/security.service.ts +++ b/src/app/services/security.service.ts @@ -2,89 +2,94 @@ import { Injectable } from '@angular/core'; import { base64 } from '@scure/base'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class SecurityService { - private encoder = new TextEncoder(); - private decoder = new TextDecoder(); + private encoder = new TextEncoder(); + private decoder = new TextDecoder(); - private async getPasswordKey(password: string): Promise { - return window.crypto.subtle.importKey( - 'raw', - this.encoder.encode(password), - 'PBKDF2', - false, - ['deriveKey'] - ); - } + private async getPasswordKey(password: string): Promise { + return window.crypto.subtle.importKey( + 'raw', + this.encoder.encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + } - private async deriveKey( - passwordKey: CryptoKey, - salt: Uint8Array, - keyUsage: KeyUsage[] - ): Promise { - return window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: salt, - iterations: 250000, - hash: 'SHA-256', - }, - passwordKey, - { name: 'AES-GCM', length: 256 }, - false, - keyUsage - ); - } + private async deriveKey( + passwordKey: CryptoKey, + salt: Uint8Array, + keyUsage: KeyUsage[] + ): Promise { + return window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 250000, + hash: 'SHA-256', + }, + passwordKey, + { name: 'AES-GCM', length: 256 }, + false, + keyUsage + ); + } - async encryptData(secretData: string, password: string): Promise { - try { - const salt = window.crypto.getRandomValues(new Uint8Array(16)); - const iv = window.crypto.getRandomValues(new Uint8Array(12)); - const passwordKey = await this.getPasswordKey(password); - const aesKey = await this.deriveKey(passwordKey, salt, ['encrypt']); + async encryptData(secretData: string, password: string): Promise { + try { + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + const passwordKey = await this.getPasswordKey(password); + const aesKey = await this.deriveKey(passwordKey, salt, ['encrypt']); - const encryptedContent = new Uint8Array( - await window.crypto.subtle.encrypt( - { name: 'AES-GCM', iv: iv }, - aesKey, - this.encoder.encode(secretData) - ) - ); + const encryptedContent = new Uint8Array( + await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + aesKey, + this.encoder.encode(secretData) + ) + ); - const encryptedData = new Uint8Array(salt.length + iv.length + encryptedContent.length); - encryptedData.set(salt, 0); - encryptedData.set(iv, salt.length); - encryptedData.set(encryptedContent, salt.length + iv.length); + const encryptedData = new Uint8Array( + salt.length + iv.length + encryptedContent.length + ); + encryptedData.set(salt, 0); + encryptedData.set(iv, salt.length); + encryptedData.set(encryptedContent, salt.length + iv.length); - return base64.encode(encryptedData); - } catch (e) { - console.error('Encryption failed:', e); - throw new Error('Failed to encrypt data.'); + return base64.encode(encryptedData); + } catch (e) { + console.error('Encryption failed:', e); + throw new Error('Failed to encrypt data.'); + } } - } - async decryptData(encryptedData: string, password: string): Promise { - try { - const encryptedDataBuff = base64.decode(encryptedData); + async decryptData( + encryptedData: string, + password: string + ): Promise { + try { + const encryptedDataBuff = base64.decode(encryptedData); - const salt = encryptedDataBuff.slice(0, 16); - const iv = encryptedDataBuff.slice(16, 28); - const data = encryptedDataBuff.slice(28); + const salt = encryptedDataBuff.slice(0, 16); + const iv = encryptedDataBuff.slice(16, 28); + const data = encryptedDataBuff.slice(28); - const passwordKey = await this.getPasswordKey(password); - const aesKey = await this.deriveKey(passwordKey, salt, ['decrypt']); + const passwordKey = await this.getPasswordKey(password); + const aesKey = await this.deriveKey(passwordKey, salt, ['decrypt']); - const decryptedContent = await window.crypto.subtle.decrypt( - { name: 'AES-GCM', iv: iv }, - aesKey, - data - ); + const decryptedContent = await window.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + aesKey, + data + ); - return this.decoder.decode(decryptedContent); - } catch (e) { - console.error('Decryption failed:', e); - throw new Error('Failed to decrypt data.'); + return this.decoder.decode(decryptedContent); + } catch (e) { + console.error('Decryption failed:', e); + throw new Error('Failed to decrypt data.'); + } } - } } diff --git a/src/app/services/signer.service.ts b/src/app/services/signer.service.ts index 0b6a3d4b..10489360 100644 --- a/src/app/services/signer.service.ts +++ b/src/app/services/signer.service.ts @@ -1,33 +1,40 @@ import { Injectable } from '@angular/core'; -import { UnsignedEvent, nip19, getPublicKey, nip04, Event, generateSecretKey, finalizeEvent } from 'nostr-tools'; +import { MatDialog } from '@angular/material/dialog'; +import { hexToBytes } from '@noble/hashes/utils'; +import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dialog.component'; import { Buffer } from 'buffer'; +import { + Event, + UnsignedEvent, + finalizeEvent, + generateSecretKey, + getPublicKey, + nip04, + nip19, +} from 'nostr-tools'; import { privateKeyFromSeedWords } from 'nostr-tools/nip06'; import { SecurityService } from './security.service'; -import { hexToBytes } from '@noble/hashes/utils'; -import { MatDialog } from '@angular/material/dialog'; -import { PasswordDialogComponent } from 'app/shared/password-dialog/password-dialog.component'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class SignerService { - - localStorageSecretKeyName: string = "secretKey"; - localStoragePublicKeyName: string = "publicKey"; - localStorageNpubName: string = "npub"; - localStorageNsecName: string = "nsec"; + localStorageSecretKeyName: string = 'secretKey'; + localStoragePublicKeyName: string = 'publicKey'; + localStorageNpubName: string = 'npub'; + localStorageNsecName: string = 'nsec'; private storageKey = 'userPassword'; constructor( private securityService: SecurityService, private dialog: MatDialog - ) { } + ) {} savePassword(password: string, durationInMinutes: number): void { const expirationTime = Date.now() + durationInMinutes * 60 * 1000; const passwordData = { password, - expirationTime + expirationTime, }; sessionStorage.setItem(this.storageKey, JSON.stringify(passwordData)); } @@ -50,7 +57,11 @@ export class SignerService { sessionStorage.removeItem(this.storageKey); } - async changePassword(currentPassword: string, newPassword: string , savePassword:boolean): Promise { + async changePassword( + currentPassword: string, + newPassword: string, + savePassword: boolean + ): Promise { try { const secretKey = await this.getSecretKey(currentPassword); if (!secretKey) { @@ -72,18 +83,16 @@ export class SignerService { return true; } catch (error) { - console.error("Failed to change password: ", error); + console.error('Failed to change password: ', error); return false; } } - - getUsername(pubkey: string) { - if (pubkey.startsWith("npub")) { + if (pubkey.startsWith('npub')) { pubkey = nip19.decode(pubkey).data.toString(); } - return `@${(localStorage.getItem(`${pubkey}`) || nip19.npubEncode(pubkey))}`; + return `@${localStorage.getItem(`${pubkey}`) || nip19.npubEncode(pubkey)}`; } npub() { @@ -94,7 +103,7 @@ export class SignerService { async requestPassword(): Promise { const dialogRef = this.dialog.open(PasswordDialogComponent, { width: '300px', - disableClose: true + disableClose: true, }); return dialogRef.afterClosed().toPromise(); @@ -103,13 +112,14 @@ export class SignerService { async nsec(password: string) { if (this.usingSecretKey()) { let secretKey = await this.getSecretKey(password); - const secretKeyUint8Array = Uint8Array.from(Buffer.from(secretKey, 'hex')); + const secretKeyUint8Array = Uint8Array.from( + Buffer.from(secretKey, 'hex') + ); return nip19.nsecEncode(secretKeyUint8Array); } - return ""; + return ''; } - pubkey(npub: string) { return nip19.decode(npub).data.toString(); } @@ -122,7 +132,7 @@ export class SignerService { } getPublicKey() { - return localStorage.getItem(this.localStoragePublicKeyName) || ""; + return localStorage.getItem(this.localStoragePublicKeyName) || ''; } //npub=============== @@ -136,16 +146,27 @@ export class SignerService { //seckey=============== async setSecretKey(secretKey: string, password: string) { - const encryptedSecretKey = await this.securityService.encryptData(secretKey, password); - localStorage.setItem(this.localStorageSecretKeyName, encryptedSecretKey); + const encryptedSecretKey = await this.securityService.encryptData( + secretKey, + password + ); + localStorage.setItem( + this.localStorageSecretKeyName, + encryptedSecretKey + ); } async getSecretKey(password: string) { - const encryptedSecretKey = localStorage.getItem(this.localStorageSecretKeyName); + const encryptedSecretKey = localStorage.getItem( + this.localStorageSecretKeyName + ); if (!encryptedSecretKey) { return null; } - return await this.securityService.decryptData(encryptedSecretKey, password); + return await this.securityService.decryptData( + encryptedSecretKey, + password + ); } async getDecryptedSecretKey(): Promise { @@ -157,7 +178,9 @@ export class SignerService { const result = await this.requestPassword(); // Prompt user for password if not stored if (result?.password) { - const decryptedPrivateKey = await this.getSecretKey(result.password); // Check that the private key is decrypted properly + const decryptedPrivateKey = await this.getSecretKey( + result.password + ); // Check that the private key is decrypted properly if (result.duration !== 0) { this.savePassword(result.password, result.duration); } @@ -174,7 +197,10 @@ export class SignerService { //nsec=============== async setNsec(nsec: string, password: string) { - const encryptedNsec = await this.securityService.encryptData(nsec, password); + const encryptedNsec = await this.securityService.encryptData( + nsec, + password + ); localStorage.setItem(this.localStorageNsecName, encryptedNsec); } @@ -207,10 +233,14 @@ export class SignerService { } else if (/^[0-9a-fA-F]{64}$/.test(key)) { secretKey = key; } else { - throw new Error('Invalid key format. Must be either nsec or hex.'); + throw new Error( + 'Invalid key format. Must be either nsec or hex.' + ); } - const secretKeyUint8Array = new Uint8Array(Buffer.from(secretKey, 'hex')); + const secretKeyUint8Array = new Uint8Array( + Buffer.from(secretKey, 'hex') + ); pubkey = getPublicKey(secretKeyUint8Array); npub = nip19.npubEncode(pubkey); nsec = nip19.nsecEncode(secretKeyUint8Array); @@ -221,16 +251,26 @@ export class SignerService { return true; } catch (e) { - console.error("Error during key handling: ", e); + console.error('Error during key handling: ', e); return false; } } - handleLoginWithMenemonic(mnemonic: string, passphrase: string = '', password: string): boolean { + handleLoginWithMenemonic( + mnemonic: string, + passphrase: string = '', + password: string + ): boolean { try { const accountIndex = 0; - const secretKey = privateKeyFromSeedWords(mnemonic, passphrase, accountIndex); - const secretKeyUint8Array = Uint8Array.from(Buffer.from(secretKey, 'hex')); + const secretKey = privateKeyFromSeedWords( + mnemonic, + passphrase, + accountIndex + ); + const secretKeyUint8Array = Uint8Array.from( + Buffer.from(secretKey, 'hex') + ); const pubkey = getPublicKey(secretKeyUint8Array); const npub = nip19.npubEncode(pubkey); const nsec = nip19.nsecEncode(secretKeyUint8Array); @@ -242,7 +282,7 @@ export class SignerService { window.localStorage.setItem(this.localStorageNsecName, nsec); return true; } catch (error) { - console.error("Error during login with mnemonic:", error); + console.error('Error during login with mnemonic:', error); return false; } } @@ -266,7 +306,12 @@ export class SignerService { return !!localStorage.getItem(this.localStorageSecretKeyName); } - generateAndStoreKeys(password: string): { secretKey: string, pubkey: string, npub: string, nsec: string } | null { + generateAndStoreKeys(password: string): { + secretKey: string; + pubkey: string; + npub: string; + nsec: string; + } | null { try { const privateKeyUint8Array = generateSecretKey(); const secretKey = Buffer.from(privateKeyUint8Array).toString('hex'); @@ -280,20 +325,24 @@ export class SignerService { return { secretKey, pubkey, npub, nsec }; } catch (error) { - console.error("Error during key generation:", error); + console.error('Error during key generation:', error); return null; } } async handleLoginWithExtension(): Promise { - const globalContext = globalThis as unknown as { nostr?: { getPublicKey?: Function } }; + const globalContext = globalThis as unknown as { + nostr?: { getPublicKey?: Function }; + }; if (!globalContext.nostr) { return false; } try { const pubkey = await globalContext.nostr.getPublicKey(); if (!pubkey) { - throw new Error("Public key not available from Nostr extension."); + throw new Error( + 'Public key not available from Nostr extension.' + ); } this.setPublicKeyFromExtension(pubkey); @@ -304,13 +353,18 @@ export class SignerService { } } - - - - async encryptMessage(privateKey: string, recipientPublicKey: string, message: string): Promise { + async encryptMessage( + privateKey: string, + recipientPublicKey: string, + message: string + ): Promise { console.log(message); try { - const encryptedMessage = await nip04.encrypt(privateKey, recipientPublicKey, message); + const encryptedMessage = await nip04.encrypt( + privateKey, + recipientPublicKey, + message + ); return encryptedMessage; } catch (error) { console.error('Error encrypting message:', error); @@ -318,41 +372,54 @@ export class SignerService { } } - async encryptMessageWithExtension(content: string, pubKey: string): Promise { + async encryptMessageWithExtension( + content: string, + pubKey: string + ): Promise { const gt = globalThis as any; const encryptedMessage = await gt.nostr.nip04.encrypt(pubKey, content); return encryptedMessage; } - // Messaging (NIP-04) - async decryptMessageWithExtension(pubkey: string, ciphertext: string): Promise { + async decryptMessageWithExtension( + pubkey: string, + ciphertext: string + ): Promise { const gt = globalThis as any; // Check if Nostr extension and decrypt function are available if (gt.nostr && typeof gt.nostr.nip04?.decrypt === 'function') { try { // Attempt to decrypt the message using the Nostr extension - const decryptedContent = await gt.nostr.nip04.decrypt(pubkey, ciphertext); + const decryptedContent = await gt.nostr.nip04.decrypt( + pubkey, + ciphertext + ); return decryptedContent; } catch (error) { console.error('Decryption failed:', error); - return "*Failed to decrypt content: " + error.message + "*"; + return '*Failed to decrypt content: ' + error.message + '*'; } } // If the Nostr extension is not available console.warn('Nostr extension or decrypt method is unavailable'); - return "Attempted Nostr Window decryption and failed."; + return 'Attempted Nostr Window decryption and failed.'; } - // NIP-04: Decrypting Direct Messages - async decryptMessage(privateKey: string, senderPublicKey: string, encryptedMessage: string): Promise { + async decryptMessage( + privateKey: string, + senderPublicKey: string, + encryptedMessage: string + ): Promise { try { // Check if privateKey, senderPublicKey, and encryptedMessage are provided if (!privateKey || !senderPublicKey || !encryptedMessage) { - throw new Error('Private key, public key, or encrypted message is missing or undefined.'); + throw new Error( + 'Private key, public key, or encrypted message is missing or undefined.' + ); } // Log for debugging purposes (ensure these are correct) @@ -362,7 +429,11 @@ export class SignerService { // console.log('Encrypted Message:', encryptedMessage); // Attempt to decrypt the message using nip04.decrypt - const decryptedMessage = await nip04.decrypt(privateKey, senderPublicKey, encryptedMessage); + const decryptedMessage = await nip04.decrypt( + privateKey, + senderPublicKey, + encryptedMessage + ); // Check if the decrypted message is valid if (!decryptedMessage) { @@ -376,10 +447,6 @@ export class SignerService { } } - - - - getUnsignedEvent(kind: number, tags: string[][], content: string) { const eventUnsigned: UnsignedEvent = { kind: kind, @@ -387,48 +454,76 @@ export class SignerService { tags: tags, content: content, created_at: Math.floor(Date.now() / 1000), - } - return eventUnsigned + }; + return eventUnsigned; } getSignedEvent(eventUnsigned: UnsignedEvent, privateKey: string): Event { const privateKeyBytes = hexToBytes(privateKey); - const signedEvent: Event = finalizeEvent(eventUnsigned, privateKeyBytes); + const signedEvent: Event = finalizeEvent( + eventUnsigned, + privateKeyBytes + ); return signedEvent; } + getMuteList() { + return (localStorage.getItem('muteList') || '').split(','); + } + setMuteListFromTags(tags: string[][]): void { + let muteList: string[] = []; + tags.forEach((t) => { + muteList.push(t[1]); + }); + this.setMuteList(muteList); + } + setMuteList(muteList: string[]) { + if (muteList.length === 0) { + localStorage.setItem('muteList', ''); + } else { + let muteSet = Array.from(new Set(muteList)); + localStorage.setItem( + 'muteList', + muteSet.filter((s) => s).join(',') + ); + } + } async signEventWithExtension(unsignedEvent: UnsignedEvent): Promise { const gt = globalThis as any; if (gt.nostr) { - const signedEvent = await gt.nostr.signEvent(unsignedEvent) + const signedEvent = await gt.nostr.signEvent(unsignedEvent); return signedEvent; } else { - throw new Error("Tried to sign event with extension but failed"); + throw new Error('Tried to sign event with extension but failed'); } } - async signDMWithExtension(pubkey: string, content: string): Promise { + async signDMWithExtension( + pubkey: string, + content: string + ): Promise { const gt = globalThis as any; if (gt.nostr && gt.nostr.nip04?.encrypt) { - return await gt.nostr.nip04.encrypt(pubkey, content) + return await gt.nostr.nip04.encrypt(pubkey, content); } - throw new Error("Failed to Sign with extension"); + throw new Error('Failed to Sign with extension'); } public async isUsingExtension(): Promise { const globalContext = globalThis as any; if (globalContext.nostr && globalContext.nostr.getPublicKey) { try { - const secretKey = localStorage.getItem(this.localStorageSecretKeyName); + const secretKey = localStorage.getItem( + this.localStorageSecretKeyName + ); return !secretKey; - } catch (error) { - console.error("Failed to check Nostr extension:", error); + console.error('Failed to check Nostr extension:', error); return false; } } diff --git a/src/app/services/social.service.ts b/src/app/services/social.service.ts index a9d0ed5e..d56730a3 100644 --- a/src/app/services/social.service.ts +++ b/src/app/services/social.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; -import { Filter, NostrEvent, UnsignedEvent, Event } from 'nostr-tools'; +import { Event, Filter, NostrEvent, UnsignedEvent } from 'nostr-tools'; +import { Observable, Subject } from 'rxjs'; import { RelayService } from './relay.service'; import { SignerService } from './signer.service'; -import { Subject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -14,7 +14,7 @@ export class SocialService { constructor( private relayService: RelayService, private signerService: SignerService - ) { } + ) {} getFollowersObservable(): Observable { return this.followersSubject.asObservable(); @@ -95,7 +95,12 @@ export class SocialService { const newFollowingList = [...currentFollowing, pubkeyToFollow]; this.setFollowingList(newFollowingList); - const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(3, newFollowingList.map(f => ['p', f]), ''); + const unsignedEvent: UnsignedEvent = + this.signerService.getUnsignedEvent( + 3, + newFollowingList.map((f) => ['p', f]), + '' + ); // Check if using Nostr extension const isUsingExtension = await this.signerService.isUsingExtension(); @@ -103,14 +108,18 @@ export class SocialService { if (isUsingExtension) { // Sign using Nostr extension - signedEvent = await this.signerService.signEventWithExtension(unsignedEvent); + signedEvent = + await this.signerService.signEventWithExtension(unsignedEvent); } else { // Sign using private key const secretKey = await this.signerService.getDecryptedSecretKey(); if (!secretKey) { throw new Error('Secret key is missing. Unable to follow.'); } - signedEvent = this.signerService.getSignedEvent(unsignedEvent, secretKey); + signedEvent = this.signerService.getSignedEvent( + unsignedEvent, + secretKey + ); } // Publish the signed follow event @@ -128,10 +137,17 @@ export class SocialService { } // Remove the user from the following list - const updatedFollowingList = currentFollowing.filter((pubkey) => pubkey !== pubkeyToUnfollow); + const updatedFollowingList = currentFollowing.filter( + (pubkey) => pubkey !== pubkeyToUnfollow + ); this.setFollowingList(updatedFollowingList); - const unsignedEvent: UnsignedEvent = this.signerService.getUnsignedEvent(3, updatedFollowingList.map(f => ['p', f]), ''); + const unsignedEvent: UnsignedEvent = + this.signerService.getUnsignedEvent( + 3, + updatedFollowingList.map((f) => ['p', f]), + '' + ); // Check if using Nostr extension const isUsingExtension = await this.signerService.isUsingExtension(); @@ -139,14 +155,18 @@ export class SocialService { if (isUsingExtension) { // Sign using Nostr extension - signedEvent = await this.signerService.signEventWithExtension(unsignedEvent); + signedEvent = + await this.signerService.signEventWithExtension(unsignedEvent); } else { // Sign using private key const secretKey = await this.signerService.getDecryptedSecretKey(); if (!secretKey) { throw new Error('Secret key is missing. Unable to unfollow.'); } - signedEvent = this.signerService.getSignedEvent(unsignedEvent, secretKey); + signedEvent = this.signerService.getSignedEvent( + unsignedEvent, + secretKey + ); } // Publish the signed unfollow event @@ -170,7 +190,6 @@ export class SocialService { return tags; } - setFollowingListFromTags(tags: string[][]): void { const following: string[] = []; tags.forEach((t) => { diff --git a/src/app/services/state.service.ts b/src/app/services/state.service.ts index 72276d5c..dd065fc4 100644 --- a/src/app/services/state.service.ts +++ b/src/app/services/state.service.ts @@ -3,42 +3,44 @@ import { Project } from 'app/interface/project.interface'; import { BehaviorSubject } from 'rxjs'; @Injectable({ - providedIn: 'root', + providedIn: 'root', }) export class StateService { - private projects: Project[] = []; - private projectsSubject = new BehaviorSubject([]); + private projects: Project[] = []; + private projectsSubject = new BehaviorSubject([]); - getProjectsObservable() { - return this.projectsSubject.asObservable(); - } + getProjectsObservable() { + return this.projectsSubject.asObservable(); + } - setProjects(projects: Project[]): void { - this.projects = projects; - this.projectsSubject.next(this.projects); - } + setProjects(projects: Project[]): void { + this.projects = projects; + this.projectsSubject.next(this.projects); + } - getProjects(): Project[] { - return this.projects; - } + getProjects(): Project[] { + return this.projects; + } - hasProjects(): boolean { - return this.projects.length > 0; - } + hasProjects(): boolean { + return this.projects.length > 0; + } - updateProject(project: Project): void { - const index = this.projects.findIndex(p => p.nostrPubKey === project.nostrPubKey); + updateProject(project: Project): void { + const index = this.projects.findIndex( + (p) => p.nostrPubKey === project.nostrPubKey + ); - if (index > -1) { - this.projects[index] = project; - } else { - this.projects.push(project); - } + if (index > -1) { + this.projects[index] = project; + } else { + this.projects.push(project); + } - this.projectsSubject.next(this.projects); - } + this.projectsSubject.next(this.projects); + } - getProjectByPubKey(nostrPubKey: string): Project | undefined { - return this.projects.find(p => p.nostrPubKey === nostrPubKey); - } + getProjectByPubKey(nostrPubKey: string): Project | undefined { + return this.projects.find((p) => p.nostrPubKey === nostrPubKey); + } } diff --git a/src/app/services/update.service.ts b/src/app/services/update.service.ts index c05f390d..2158c444 100644 --- a/src/app/services/update.service.ts +++ b/src/app/services/update.service.ts @@ -12,7 +12,7 @@ export class NewVersionCheckerService { constructor( private swUpdate: SwUpdate, - private zone: NgZone, + private zone: NgZone ) { this.checkForUpdateOnLoad(); this.checkForUpdateOnInterval(); @@ -29,22 +29,23 @@ export class NewVersionCheckerService { this.unsubscribeInterval(); if (!this.swUpdate.isEnabled) { - console.log('Service worker updates are disabled.'); return; } this.zone.runOutsideAngular(() => { - this.intervalSubscription = this.intervalSource.subscribe(async () => { - try { - const updateAvailable = await this.swUpdate.checkForUpdate(); - console.log(updateAvailable ? 'A new version is available.' : 'Already on the latest version.'); - if (updateAvailable) { - this.newVersionAvailableSubject.next(true); + this.intervalSubscription = this.intervalSource.subscribe( + async () => { + try { + const updateAvailable = + await this.swUpdate.checkForUpdate(); + if (updateAvailable) { + this.newVersionAvailableSubject.next(true); + } + } catch (error) { + console.error('Failed to check for updates:', error); } - } catch (error) { - console.error('Failed to check for updates:', error); } - }); + ); }); } @@ -52,32 +53,29 @@ export class NewVersionCheckerService { this.unsubscribeNewVersion(); if (!this.swUpdate.isEnabled) { - console.log('Service worker updates are disabled for this app.'); return; } - this.newVersionSubscription = this.swUpdate.versionUpdates.subscribe((evt: VersionEvent) => { - console.log('New version update event:', evt); - switch (evt.type) { - case 'VERSION_DETECTED': - console.log(`Downloading new app version: ${evt.version.hash}`); - break; - case 'VERSION_READY': - console.log(`New app version is ready for use: ${evt.latestVersion.hash}`); - this.newVersionAvailableSubject.next(true); - break; - case 'VERSION_INSTALLATION_FAILED': - console.error(`Failed to install app version '${evt.version.hash}': ${evt.error}`); - break; - default: - console.log('Unknown version event type:', evt.type); + this.newVersionSubscription = this.swUpdate.versionUpdates.subscribe( + (evt: VersionEvent) => { + switch (evt.type) { + case 'VERSION_DETECTED': + break; + case 'VERSION_READY': + this.newVersionAvailableSubject.next(true); + break; + case 'VERSION_INSTALLATION_FAILED': + console.error( + `Failed to install app version '${evt.version.hash}': ${evt.error}` + ); + break; + default: + // console.log('Unknown version event type:', evt.type); + } } - }); - - console.log('Subscribed to new version updates.'); + ); } - private unsubscribeInterval(): void { if (this.intervalSubscription) { this.intervalSubscription.unsubscribe(); diff --git a/src/app/shared/gif-dialog/gif-dialog.component.html b/src/app/shared/gif-dialog/gif-dialog.component.html index 63a3ad54..414333a3 100644 --- a/src/app/shared/gif-dialog/gif-dialog.component.html +++ b/src/app/shared/gif-dialog/gif-dialog.component.html @@ -1,18 +1,39 @@
Search GIF - + - - Enter a keyword like "funny" or "cat" and hit search! + Enter a keyword like "funny" or "cat" and hit search! -
-
- +
+
+
diff --git a/src/app/shared/gif-dialog/gif-dialog.component.ts b/src/app/shared/gif-dialog/gif-dialog.component.ts index a21b79f8..306d45a4 100644 --- a/src/app/shared/gif-dialog/gif-dialog.component.ts +++ b/src/app/shared/gif-dialog/gif-dialog.component.ts @@ -1,10 +1,19 @@ import { CommonModule, NgClass } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, +} from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormField, MatFormFieldControl, MatFormFieldModule, MatLabel } from '@angular/material/form-field'; +import { + MatFormField, + MatFormFieldModule, + MatLabel, +} from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; @@ -44,32 +53,31 @@ import { GifService } from 'app/services/gif.service'; MatInputModule, MatSelectModule, ], - styles: [` -.full-width { - width: 100%; -} - -.results-container { - max-height: 300px; - - overflow-y: auto; -overflow-x:hidden; -} + styles: [ + ` + .full-width { + width: 100%; + } -.gif-preview { - transition: transform 0.2s; -} + .results-container { + max-height: 300px; -.gif-preview:hover { - transform: scale(1.1); -} + overflow-y: auto; + overflow-x: hidden; + } + .gif-preview { + transition: transform 0.2s; + } - `] + .gif-preview:hover { + transform: scale(1.1); + } + `, + ], }) export class GifDialogComponent { - - gifSearch: string = ""; + gifSearch: string = ''; gifsFound: string[] = []; constructor( @@ -77,7 +85,7 @@ export class GifDialogComponent { public dialogRef: MatDialogRef, private cdr: ChangeDetectorRef, @Inject(MAT_DIALOG_DATA) public data: any - ) { } + ) {} searchGif() { if (!this.gifSearch || this.gifSearch.trim() === '') { @@ -86,12 +94,19 @@ export class GifDialogComponent { } if (this.data.apiKey) { - this.gifService.getTopGifs(this.gifSearch, this.data.apiKey).subscribe(response => { - this.gifsFound = response.results.map(gif => gif.media[0].gif.url); - this.cdr.detectChanges(); - }, error => { - console.error('Error fetching GIFs:', error); - }); + this.gifService + .getTopGifs(this.gifSearch, this.data.apiKey) + .subscribe( + (response) => { + this.gifsFound = response.results.map( + (gif) => gif.media[0].gif.url + ); + this.cdr.detectChanges(); + }, + (error) => { + console.error('Error fetching GIFs:', error); + } + ); } else { console.error('API key is missing.'); } diff --git a/src/app/shared/password-dialog/password-dialog.component.html b/src/app/shared/password-dialog/password-dialog.component.html index 907cad9a..be651f7e 100644 --- a/src/app/shared/password-dialog/password-dialog.component.html +++ b/src/app/shared/password-dialog/password-dialog.component.html @@ -1,34 +1,39 @@

Enter Password

-
- - - Password - lock - - - Password is required - - + + + + Password + lock + + + Password is required + + - - - - Duration - - 0 Minute - 1 Minute - 5 Minutes - 10 Minutes - 1 Hour - - - -
+ + + Duration + + 0 Minute + 1 Minute + 5 Minutes + 10 Minutes + 1 Hour + + +
- - + +
diff --git a/src/app/shared/password-dialog/password-dialog.component.ts b/src/app/shared/password-dialog/password-dialog.component.ts index f3cb85f8..0ca1e582 100644 --- a/src/app/shared/password-dialog/password-dialog.component.ts +++ b/src/app/shared/password-dialog/password-dialog.component.ts @@ -1,10 +1,20 @@ import { CommonModule, NgClass } from '@angular/common'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; -import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { + FormBuilder, + FormGroup, + FormsModule, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatOption } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatFormField, MatFormFieldControl, MatFormFieldModule, MatLabel } from '@angular/material/form-field'; +import { + MatFormField, + MatFormFieldModule, + MatLabel, +} from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; @@ -38,17 +48,20 @@ import { SettingsSecurityComponent } from 'app/components/settings/security/secu MatFormField, ReactiveFormsModule, CommonModule, - MatSelectModule , + MatSelectModule, MatFormFieldModule, MatInputModule, MatSelectModule, ], - styles: [` - .full-width { width: 100%; } - `] + styles: [ + ` + .full-width { + width: 100%; + } + `, + ], }) export class PasswordDialogComponent { - passwordForm: FormGroup; constructor( @@ -58,7 +71,7 @@ export class PasswordDialogComponent { ) { this.passwordForm = this.fb.group({ password: ['', Validators.required], - duration: ['0', Validators.required] // Default duration is 0 minute + duration: ['0', Validators.required], // Default duration is 0 minute }); } diff --git a/src/app/shared/pipes/ago.pipe.ts b/src/app/shared/pipes/ago.pipe.ts index 005f7886..384ad0e9 100644 --- a/src/app/shared/pipes/ago.pipe.ts +++ b/src/app/shared/pipes/ago.pipe.ts @@ -1,16 +1,14 @@ import { Pipe, PipeTransform } from '@angular/core'; import * as moment from 'moment'; -@Pipe({ name: 'ago', standalone: true }) +@Pipe({ name: 'ago', standalone: true }) export class AgoPipe implements PipeTransform { - - transform(value: number): string { - - if (value === 0) { - return ''; - } - - const date = moment.unix(value); - return date.fromNow(); - } + transform(value: number): string { + if (value === 0) { + return ''; + } + + const date = moment.unix(value); + return date.fromNow(); + } } diff --git a/src/app/shared/pipes/checkmessage.pipe.ts b/src/app/shared/pipes/checkmessage.pipe.ts index 488c55f0..dc2ae50c 100644 --- a/src/app/shared/pipes/checkmessage.pipe.ts +++ b/src/app/shared/pipes/checkmessage.pipe.ts @@ -1,50 +1,52 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'checkmessage', standalone: true + name: 'checkmessage', + standalone: true, }) export class CheckmessagePipe implements PipeTransform { + transform(value: string): string { + const imageRegex = /\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/i; + const videoRegex = /\.(mp4|mov|avi|mkv|webm|flv|wmv|mpeg|3gp)$/i; + const audioRegex = /\.(mp3|wav|ogg|m4a|aac|flac)$/i; + const pdfRegex = /\.pdf$/i; + const youtubeRegex = + /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; + const vimeoRegex = /https?:\/\/(www\.)?vimeo.com\/(\d+)/; + const instagramRegex = + /https?:\/\/(www\.)?instagram.com\/p\/[a-zA-Z0-9_-]+/; + const twitterRegex = + /https?:\/\/(www\.)?twitter.com\/[a-zA-Z0-9_]+\/status\/[0-9]+/; + const urlRegex = /(https?:\/\/[^\s]+)/; - transform(value: string): string { + if (imageRegex.test(value)) { + return '๐ŸŒ„ image'; + } + if (videoRegex.test(value)) { + return '๐ŸŽฌ video'; + } + if (audioRegex.test(value)) { + return '๐ŸŽต audio'; + } + if (pdfRegex.test(value)) { + return '๐Ÿ“ pdf'; + } + if (youtubeRegex.test(value)) { + return '๐Ÿ“ฝ๏ธ youtube'; + } + if (vimeoRegex.test(value)) { + return '๐Ÿ“ฝ๏ธ vimeo'; + } + if (instagramRegex.test(value)) { + return '๐Ÿ”ฎ instagram'; + } + if (twitterRegex.test(value)) { + return '๐Ÿฆ twitter'; + } + if (urlRegex.test(value)) { + return '๐ŸŒ url'; + } - const imageRegex = /\.(jpeg|jpg|gif|png|bmp|svg|webp|tiff)$/i; - const videoRegex = /\.(mp4|mov|avi|mkv|webm|flv|wmv|mpeg|3gp)$/i; - const audioRegex = /\.(mp3|wav|ogg|m4a|aac|flac)$/i; - const pdfRegex = /\.pdf$/i; - const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:[^\/\n\s]+\/\S+\/|(?:v|e(?:mbed)?)\/|\S*?[?&]v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; - const vimeoRegex = /https?:\/\/(www\.)?vimeo.com\/(\d+)/; - const instagramRegex = /https?:\/\/(www\.)?instagram.com\/p\/[a-zA-Z0-9_-]+/; - const twitterRegex = /https?:\/\/(www\.)?twitter.com\/[a-zA-Z0-9_]+\/status\/[0-9]+/; - const urlRegex = /(https?:\/\/[^\s]+)/; - - if (imageRegex.test(value)) { - return '๐ŸŒ„ image'; - } - if (videoRegex.test(value)) { - return '๐ŸŽฌ video'; - } - if (audioRegex.test(value)) { - return '๐ŸŽต audio'; - } - if (pdfRegex.test(value)) { - return '๐Ÿ“ pdf'; - } - if (youtubeRegex.test(value)) { - return '๐Ÿ“ฝ๏ธ youtube'; + return value; } - if (vimeoRegex.test(value)) { - return '๐Ÿ“ฝ๏ธ vimeo'; - } - if (instagramRegex.test(value)) { - return '๐Ÿ”ฎ instagram'; - } - if (twitterRegex.test(value)) { - return '๐Ÿฆ twitter'; - } - if (urlRegex.test(value)) { - return '๐ŸŒ url'; - } - - return value; - } } diff --git a/src/app/shared/pipes/safe-url.pipe.ts b/src/app/shared/pipes/safe-url.pipe.ts index 0fcf90bc..f5726c73 100644 --- a/src/app/shared/pipes/safe-url.pipe.ts +++ b/src/app/shared/pipes/safe-url.pipe.ts @@ -2,13 +2,13 @@ import { Pipe, PipeTransform } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; @Pipe({ - name: 'safeResourceUrl', - standalone: true, + name: 'safeResourceUrl', + standalone: true, }) export class SafeUrlPipe implements PipeTransform { - constructor(private readonly sanitizer: DomSanitizer) {} + constructor(private readonly sanitizer: DomSanitizer) {} - public transform(url: string): SafeResourceUrl { - return this.sanitizer.bypassSecurityTrustResourceUrl(url); - } + public transform(url: string): SafeResourceUrl { + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } } diff --git a/src/app/shared/pipes/size.pipe.ts b/src/app/shared/pipes/size.pipe.ts index 03c47fc6..d184f30e 100644 --- a/src/app/shared/pipes/size.pipe.ts +++ b/src/app/shared/pipes/size.pipe.ts @@ -1,23 +1,23 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'size', - standalone: true, + name: 'size', + standalone: true, }) export class SizePipe implements PipeTransform { - transform(value: any, args?: any): any { - if (value == null) { - return ''; - } + transform(value: any, args?: any): any { + if (value == null) { + return ''; + } - if (value === 0) { - return '0 Bytes'; - } + if (value === 0) { + return '0 Bytes'; + } - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(value) / Math.log(k)); + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(value) / Math.log(k)); - return parseFloat((value / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } + return parseFloat((value / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } } diff --git a/src/app/shared/pipes/stripHtml.ts b/src/app/shared/pipes/stripHtml.ts index 9e4745b9..3f8bbfb2 100644 --- a/src/app/shared/pipes/stripHtml.ts +++ b/src/app/shared/pipes/stripHtml.ts @@ -1,31 +1,31 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'stripHtml', - standalone: true, + name: 'stripHtml', + standalone: true, }) export class StripHtmlPipe implements PipeTransform { - transform(value: string, limit: number = 200): string { - if (!value) { - return '...'; - } + transform(value: string, limit: number = 200): string { + if (!value) { + return '...'; + } - // Strip HTML tags and replace them with a space - let strippedText = value.replace(/<\/?[^>]+(>|$)/g, ' '); + // Strip HTML tags and replace them with a space + let strippedText = value.replace(/<\/?[^>]+(>|$)/g, ' '); - // Replace   with a space - strippedText = strippedText.replace(/ /g, ' '); + // Replace   with a space + strippedText = strippedText.replace(/ /g, ' '); - // Remove extra spaces - const normalizedText = strippedText.replace(/\s\s+/g, ' ').trim(); + // Remove extra spaces + const normalizedText = strippedText.replace(/\s\s+/g, ' ').trim(); - // Limit the text to the specified number of characters - let result = normalizedText; - if (normalizedText.length > limit) { - result = normalizedText.substring(0, limit); - } + // Limit the text to the specified number of characters + let result = normalizedText; + if (normalizedText.length > limit) { + result = normalizedText.substring(0, limit); + } - // Always add "..." at the end - return result.trim() + '...'; - } + // Always add "..." at the end + return result.trim() + '...'; + } } diff --git a/src/app/shared/pipes/timestamp.pipe.ts b/src/app/shared/pipes/timestamp.pipe.ts index d2975d9b..85a8a0ae 100644 --- a/src/app/shared/pipes/timestamp.pipe.ts +++ b/src/app/shared/pipes/timestamp.pipe.ts @@ -1,16 +1,14 @@ import { Pipe, PipeTransform } from '@angular/core'; import * as moment from 'moment'; - @Pipe({ name: 'timestamp' }) export class TimestampPipe implements PipeTransform { - transform(value: number): any { - - if (value === 0) { - return ''; - } - - const date = moment.unix(value); - return date.toDate(); - } + transform(value: number): any { + if (value === 0) { + return ''; + } + + const date = moment.unix(value); + return date.toDate(); + } } diff --git a/src/app/shared/pipes/truncate.pipe.ts b/src/app/shared/pipes/truncate.pipe.ts index 4ada2d62..5956ac6f 100644 --- a/src/app/shared/pipes/truncate.pipe.ts +++ b/src/app/shared/pipes/truncate.pipe.ts @@ -1,13 +1,12 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'truncate' + name: 'truncate', }) export class TruncatePipe implements PipeTransform { - - transform(value: string, limit: number): string { - if (!value) return ''; - if (value.length <= limit) return value; - return value.substring(0, limit) + '...'; - } + transform(value: string, limit: number): string { + if (!value) return ''; + if (value.length <= limit) return value; + return value.substring(0, limit) + '...'; + } } diff --git a/src/app/utils.ts b/src/app/shared/utils.ts similarity index 61% rename from src/app/utils.ts rename to src/app/shared/utils.ts index faebed99..4f2f801d 100644 --- a/src/app/utils.ts +++ b/src/app/shared/utils.ts @@ -1,27 +1,22 @@ - + import { User } from 'app/types/user'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { Post } from './types/post'; -import { User } from './types/user'; dayjs.extend(relativeTime); - -export function range (start: number, end: number) { - return [...Array(1+end-start).keys()].map(v => start+v) +export function range(start: number, end: number) { + return [...Array(1 + end - start).keys()].map((v) => start + v); } export function ellipsis(value: string): string { - // truncates the middle of the string - if (value.length < 40) return value; + if (value.length < 40) return value; let third: number = value.length / 8; let finalThird: number = value.length - third; - return value.substring(0, third) + ":" + value.substring(finalThird) + return value.substring(0, third) + ':' + value.substring(finalThird); } - export function humantime(value: number): string { - let date = new Date(value*1000) - return dayjs(date).fromNow() + let date = new Date(value * 1000); + return dayjs(date).fromNow(); } export class Paginator { @@ -31,13 +26,17 @@ export class Paginator { since: number = 0; baseTimeDiff: number; originalBaseTimeDiff: number; - constructor(until: number = 0, since: number = 0, baseTimeDiff: number = 15) { + constructor( + until: number = 0, + since: number = 0, + baseTimeDiff: number = 15 + ) { this.until = until; this.setDefaultUntil(); this.baseTimeDiff = baseTimeDiff; this.originalBaseTimeDiff = this.baseTimeDiff; if (since === 0) { - this.setDefaultSince() + this.setDefaultSince(); } else { this.setDefaultSince(since); } @@ -45,20 +44,7 @@ export class Paginator { this.previousUntil = until; } - incrementFilterTimes(posts: Post[]): void { - const oldestPost = posts.at(-1); - if (oldestPost) { - this.revertBackToOriginalBaseTimeDiff(); - this.setUntil(oldestPost.createdAt); - this.setSince(oldestPost.createdAt); - } else { - // posts must be empty so increment more - this.updateBaseTimeToFindPosts(); - this.setDefaultUntil(); - // expand time until we find something - this.setDefaultSince(); - } - } + incrementUserTimes(users: User[]): void { const oldestPost = users.at(-1); @@ -67,10 +53,10 @@ export class Paginator { this.setUntil(oldestPost.createdAt); this.setSince(oldestPost.createdAt); } else { - // posts must be empty so increment more + this.updateBaseTimeToFindPosts(); this.setDefaultUntil(); - // expand time until we find something + this.setDefaultSince(); } } @@ -88,16 +74,16 @@ export class Paginator { } revertBackToOriginalBaseTimeDiff() { - // revert back once we have found posts + this.baseTimeDiff = this.originalBaseTimeDiff; } getSinceAsDate(): Date { - return new Date(this.since*1000); + return new Date(this.since * 1000); } getUntilAsDate(): Date { - return new Date(this.until*1000); + return new Date(this.until * 1000); } getSinceFromNow(): string { @@ -111,8 +97,11 @@ export class Paginator { } printTimes(): void { - const diff = this.getUntilAsDate().getTime() - this.getSinceAsDate().getTime(); - console.log(`Until: ${this.getUntilFromNow()} | Since: ${this.getSinceFromNow()}`); + const diff = + this.getUntilAsDate().getTime() - this.getSinceAsDate().getTime(); + console.log( + `Until: ${this.getUntilFromNow()} | Since: ${this.getSinceFromNow()}` + ); console.log(`Diff: ${diff}`); } @@ -123,9 +112,13 @@ export class Paginator { } private setDefaultSince(addedMinutes: number = 0): void { - // Math.floor(Date.now() / 1000) + let now = new Date(); - const sinceDate = Math.floor(now.setMinutes(now.getMinutes() - this.baseTimeDiff - addedMinutes) / 1000); + const sinceDate = Math.floor( + now.setMinutes( + now.getMinutes() - this.baseTimeDiff - addedMinutes + ) / 1000 + ); this.since = sinceDate; } @@ -134,12 +127,16 @@ export class Paginator { } private getNewSince(createdAt: number, addedMinutes: number = 0): number { - const now = new Date(createdAt*1000); - return Math.floor(now.setMinutes(now.getMinutes() - this.baseTimeDiff - addedMinutes) / 1000); + const now = new Date(createdAt * 1000); + return Math.floor( + now.setMinutes( + now.getMinutes() - this.baseTimeDiff - addedMinutes + ) / 1000 + ); } private setUntil(createdAt: number): void { - this.previousUntil = this.until - (2 * 1000) // minus two minutes; + this.previousUntil = this.until - 2 * 1000; this.until = createdAt; } diff --git a/src/app/types/NewEvent.ts b/src/app/types/NewEvent.ts new file mode 100644 index 00000000..199cc33a --- /dev/null +++ b/src/app/types/NewEvent.ts @@ -0,0 +1,79 @@ +import { NostrEvent } from "nostr-tools"; +import { NIP10Result } from "nostr-tools/nip10"; + +export class NewEvent { + nostrEvent: NostrEvent; + kind: number; + content: string; + pubkey: string; + npub: string; + noteId: string; + createdAt: number; + date: Date; + fromNow: string; + username: string = ''; + picture: string = '/images/avatars/avatar-placeholder.png'; + replyCount: number = 0; + likeCount: number = 0; + zapCount: number = 0; + repostCount: number = 0; + likedByMe: boolean = false; + replies: NewEvent[] = []; + likers: string[] = []; + reposters: string[] = []; + zappers: string[] = []; + relatedEventIds: string[] = []; + rootEventId: string = ''; + replyToEventId: string = ''; + mentions: string[] = []; + hashtags: string[] = []; + repostedByMe: boolean = false; + nip10Result: NIP10Result; + tags: string[][] = []; + id: any; + isAReply: boolean = false; + + constructor( + id: any, + kind: number, + pubkey: string, + content: string, + noteId: string, + createdAt: number, + tags: string[][] = [] + ) { + this.kind = kind; + this.pubkey = pubkey; + this.content = content; + this.noteId = noteId; + this.createdAt = createdAt; + this.date = new Date(this.createdAt * 1000); + this.fromNow = this.calculateTimeFromNow(this.date); + this.tags = tags; + this.id = id; + } + + calculateTimeFromNow(date: Date): string { + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return `${diffInSeconds} seconds ago`; + } else if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes} minutes ago`; + } else if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours} hours ago`; + } else if (diffInSeconds < 2592000) { + const days = Math.floor(diffInSeconds / 86400); + return `${days} days ago`; + } else if (diffInSeconds < 31536000) { + const months = Math.floor(diffInSeconds / 2592000); + return `${months} months ago`; + } else { + const years = Math.floor(diffInSeconds / 31536000); + return `${years} years ago`; + } + } +} diff --git a/src/app/types/gif.ts b/src/app/types/gif.ts index 265dfed1..69874772 100644 --- a/src/app/types/gif.ts +++ b/src/app/types/gif.ts @@ -1,4 +1,3 @@ - export interface Gif { preview: string; size: number; @@ -12,11 +11,11 @@ export interface TenorGif { export interface TenorGifResponse { bg_color: string; - composite?: string + composite?: string; content_description: string; content_rating: string; created: number; - flags? : string[]; + flags?: string[]; h1_title: string; hasaudio: boolean; hascaption: boolean; diff --git a/src/app/types/nostr.ts b/src/app/types/nostr.ts index d1a11e4b..70a3488c 100644 --- a/src/app/types/nostr.ts +++ b/src/app/types/nostr.ts @@ -1,15 +1,14 @@ -import { UnsignedEvent, Event } from "nostr-tools" +import { Event, UnsignedEvent } from 'nostr-tools'; export interface NostrWindow { - getPublicKey: () => Promise - signEvent: (event: UnsignedEvent) => Promise + getPublicKey: () => Promise; + signEvent: (event: UnsignedEvent) => Promise; nip04?: { - encrypt?: (pubkey: string, plaintext: string) => Promise - decrypt?: (pubkey: string, ciphertext: string) => Promise - } + encrypt?: (pubkey: string, plaintext: string) => Promise; + decrypt?: (pubkey: string, ciphertext: string) => Promise; + }; } - export interface NIP05Names { [key: string]: string; } diff --git a/src/app/types/post.ts b/src/app/types/post.ts index 7d7bb679..c90a20c3 100644 --- a/src/app/types/post.ts +++ b/src/app/types/post.ts @@ -1,9 +1,9 @@ +import { decode } from '@gandlaf21/bolt11-decode'; +import { MetadataService } from 'app/services/metadata.service'; +import { humantime } from 'app/shared/utils'; +import dayjs from 'dayjs'; +import { Event, nip10, nip19 } from 'nostr-tools'; import { NIP10Result } from 'nostr-tools/nip10'; -import { Event, nip19, nip10 } from 'nostr-tools'; -import { decode } from "@gandlaf21/bolt11-decode"; - import dayjs from 'dayjs'; -import * as relativeTime from 'dayjs/plugin/relativeTime'; -import { humantime } from 'app/utils'; export interface TextWrap { text: string; @@ -51,21 +51,28 @@ export class Zap { date: Date; sig: string; tags: string[][]; - username: string = ""; - picture: string = ""; + username: string = ''; + picture: string = ''; receiverPubKey: string; receiverNpub: string; receiverEventId: string; - senderPubkey: string = ""; - senderNpub: string = ""; - senderMessage: string = ""; + senderPubkey: string = ''; + senderNpub: string = ''; + senderMessage: string = ''; bolt11: string; preImage: string; description: Event | null; - fromNow: string = ""; - content: string = ""; + fromNow: string = ''; + content: string = ''; satAmount: number; - constructor(id: string, kind: number, pubkey: string, created_at: number, sig: string, tags: string[][]) { + constructor( + id: string, + kind: number, + pubkey: string, + created_at: number, + sig: string, + tags: string[][] + ) { this.id = id; this.kind = kind; this.walletPubkey = pubkey; @@ -83,59 +90,61 @@ export class Zap { this.description = this.getDescription(); this.setSender(); this.createdAt = created_at; - this.date = new Date(this.createdAt*1000); + this.date = new Date(this.createdAt * 1000); this.setFromNow(); this.setContent(); } getUserPubkey() { - const p: string = "p"; + const p: string = 'p'; for (let tag of this.tags) { if (tag[0] === p) { return tag[1]; } } - return ""; + return ''; } getEventId() { - const e: string = "e"; + const e: string = 'e'; for (let tag of this.tags) { if (tag[0] === e) { return tag[1]; } } - return ""; + return ''; } getBolt11() { - const bolt: string = "bolt11"; + const bolt: string = 'bolt11'; for (let tag of this.tags) { if (tag[0] === bolt) { return tag[1]; } } - return ""; + return ''; } getPreImage() { - const pi: string = "preimage"; + const pi: string = 'preimage'; for (let tag of this.tags) { if (tag[0] === pi) { return tag[1]; } } - return ""; + return ''; } getDescription(): Event | null { - const desc: string = "description"; + const desc: string = 'description'; for (let tag of this.tags) { if (tag[0] === desc) { try { return JSON.parse(tag[1]) as Event; } catch (e) { - console.log(`couldn't parse zap receipt description: ${tag}`); + console.log( + `couldn't parse zap receipt description: ${tag}` + ); return null; } } @@ -147,8 +156,8 @@ export class Zap { if (this.bolt11) { const decodedInvoice = decode(this.bolt11); for (let s of decodedInvoice.sections) { - if (s.name === "amount") { - return Number(s.value)/1000; + if (s.name === 'amount') { + return Number(s.value) / 1000; } } } @@ -157,20 +166,32 @@ export class Zap { setContent() { if (this.description) { - let content = "
" - content = content + `
${this.satAmount} sats ZAP! ${humantime(this.createdAt)}
`; - content = content + `

To: nostr:${this.receiverNpub}

From: nostr:${this.senderNpub}

`; + let content = "
"; + content = + content + + `
${this.satAmount} sats ZAP! ${humantime(this.createdAt)}
`; + content = + content + + `

To: nostr:${this.receiverNpub}

From: nostr:${this.senderNpub}

`; if (this.receiverEventId) { - content = content + `

Note: nostr:${nip19.neventEncode({id: this.receiverEventId})}

`; + content = + content + + `

Note: nostr:${nip19.neventEncode({ id: this.receiverEventId })}

`; } if (this.senderMessage) { - content = content + `

Message: ${this.senderMessage}

`; + content = + content + + `

Message: ${this.senderMessage}

`; } - content = content + "
"; + content = content + '
'; let nip10Result = nip10.parse(this.description); - this.content = new Content(this.kind, content, nip10Result).getParsedContent() + this.content = new Content( + this.kind, + content, + nip10Result + ).getParsedContent(); } else { - this.content = "

Anon Zap

"; + this.content = '

Anon Zap

'; } } @@ -183,7 +204,7 @@ export class Zap { } setFromNow(): void { - this.fromNow = dayjs(this.date).fromNow() + this.fromNow = dayjs(this.date).fromNow(); } setUsername(pubkey: string): void { @@ -191,13 +212,16 @@ export class Zap { } setPicture(pubkey: string): void { - this.picture = localStorage.getItem(`${pubkey}_img`) || "https://axiumradonmitigations.com/wp-content/uploads/2015/01/icon-user-default.png"; + this.picture = + localStorage.getItem(`${pubkey}_img`) || + '/images/avatars/avatar-placeholder.png'; } } export function isYoutubeVideo(url: string): boolean { - var p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/ig; - return (url.match(p)) ? true : false; + var p = + /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/gi; + return url.match(p) ? true : false; } export class Content { @@ -205,11 +229,16 @@ export class Content { content: string; nip10Result: NIP10Result; addHash: boolean; - constructor(kind: number, content: string, nip10Result: NIP10Result, addHash: boolean = true) { + constructor( + kind: number, + content: string, + nip10Result: NIP10Result, + addHash: boolean = true + ) { this.kind = kind; this.content = content; this.nip10Result = nip10Result; - this.addHash = addHash + this.addHash = addHash; } getParsedContent(ignoreNIP10: boolean = false): string { @@ -239,7 +268,7 @@ export class Content { } hasEventPointer(content: string): boolean { - if (content.includes("nostr:")) { + if (content.includes('nostr:')) { return true; } return false; @@ -249,19 +278,21 @@ export class Content { if (value.length < 40) return value; let section: number = value.length / 8; let finalSection: number = value.length - section; - return value.substring(0, section) + ":" + value.substring(finalSection) + return ( + value.substring(0, section) + ':' + value.substring(finalSection) + ); } reposted(): string { if (this.nip10Result.root) { return `nostr:${this.getNevent(this.nip10Result.root)}`; } - return ""; + return ''; } wrapTextInSpan(textWrap: TextWrap): string { if (textWrap.cssClass === undefined) { - textWrap.cssClass = "hashtag" + textWrap.cssClass = 'hashtag'; } if (textWrap.npub) { return `${textWrap.text}`; @@ -271,49 +302,59 @@ export class Content { return `${textWrap.text}`; } else if (this.addHash && textWrap.addLink) { // this fixes an issue in user about not redirecting properly - textWrap.addLink = textWrap.addLink.replace('href="', 'href="/#/') - return `${textWrap.text}` + textWrap.addLink = textWrap.addLink.replace('href="', 'href="/#/'); + return `${textWrap.text}`; } - return `${textWrap.text}` + return `${textWrap.text}`; } getNpub(pubkey: string): string { - if (pubkey.startsWith("npub")) { + if (pubkey.startsWith('npub')) { return pubkey; } return nip19.npubEncode(pubkey); } getUsername(pubkey: string): string { - if (pubkey.startsWith("npub")) { - pubkey = nip19.decode(pubkey).data.toString() + if (pubkey.startsWith('npub')) { + pubkey = nip19.decode(pubkey).data.toString(); } - return `@${(localStorage.getItem(`${pubkey}`) || this.getNpub(pubkey))}` + return `@${localStorage.getItem(`${pubkey}`) || this.getNpub(pubkey)}`; } nip08Replace(content: string): string { - let userTags: string[] = content.match(/#\[\d+\]/gm) || [] + let userTags: string[] = content.match(/#\[\d+\]/gm) || []; // is this condition right? if (this.nip10Result.profiles.length !== userTags.length) { return content; } for (let i in userTags) { - let userPubkey = this.nip10Result.profiles[i].pubkey + let userPubkey = this.nip10Result.profiles[i].pubkey; let npub = this.getNpub(userPubkey); let username = this.getUsername(userPubkey); - let textWrap: TextWrap = {text: username, addLink: `href="/users/${npub}"`} - content = content.replace(userTags[i], this.wrapTextInSpan(textWrap)) + let textWrap: TextWrap = { + text: username, + addLink: `href="/users/${npub}"`, + }; + content = content.replace( + userTags[i], + this.wrapTextInSpan(textWrap) + ); } - return content + return content; } parseLightningInvoice(content: string): string { - let invoices: string[] = content.match(/(lightning:|lnbc)[a-z0-9]+/gm) || [] + let invoices: string[] = + content.match(/(lightning:|lnbc)[a-z0-9]+/gm) || []; for (let invoice of invoices) { try { - content = content.replace(invoice, this.getReplacementInvoiceHtml(invoice)); + content = content.replace( + invoice, + this.getReplacementInvoiceHtml(invoice) + ); } catch (e) { - console.log("failed to decode lightning invoice"); + console.log('failed to decode lightning invoice'); } } return content; @@ -323,67 +364,80 @@ export class Content { if (invoice) { const decodedInvoice = decode(invoice); for (let s of decodedInvoice.sections) { - if (s.name === "amount") { - return Number(s.value)/1000; + if (s.name === 'amount') { + return Number(s.value) / 1000; } } } - return ""; + return ''; } getReplacementInvoiceHtml(invoice: string) { const amount = this.getInvoiceAmount(invoice); - const r = `
Lightning Invoice: ${amount} sats

${invoice}

` + const r = `
Lightning Invoice: ${amount} sats

${invoice}

`; return r; } hashtagContent(content: string): string { let hashtagRegex = /#\w+\S/gm; - return content.replace(hashtagRegex, function(tag) { - let textWrap: TextWrap = {text: tag, cssClass: "hashtag", hashtag: `${tag.substring(1)}`} + return content.replace(hashtagRegex, function (tag) { + let textWrap: TextWrap = { + text: tag, + cssClass: 'hashtag', + hashtag: `${tag.substring(1)}`, + }; return `${textWrap.text}`; }); } cashtagContent(content: string): string { let cashtagRegex = /\$\w+\S/gm; - return content.replace(cashtagRegex, function(tag) { - let textWrap: TextWrap = {text: tag, cssClass: "hashtag", hashtag: `${tag.substring(1)}`} + return content.replace(cashtagRegex, function (tag) { + let textWrap: TextWrap = { + text: tag, + cssClass: 'hashtag', + hashtag: `${tag.substring(1)}`, + }; return `${textWrap.text}`; }); } styleUsername(content: string): string { - let usernameRegex = /@\w+/gm - return content.replace(usernameRegex, function(name) { - let textWrap: TextWrap = {text: name, cssClass: "hashtag"} - return `${textWrap.text}` + let usernameRegex = /@\w+/gm; + return content.replace(usernameRegex, function (name) { + let textWrap: TextWrap = { text: name, cssClass: 'hashtag' }; + return `${textWrap.text}`; }); } linkify(content: string): string { // TODO: could be improved - let urlRegex =/(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/ig; - return content.replace(urlRegex, function(url) { + let urlRegex = + /(\b(https?):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gi; + return content.replace(urlRegex, function (url) { // improve this? - if (url.toLowerCase().endsWith(".png") || - url.toLowerCase().endsWith(".jpg") || - url.toLowerCase().endsWith(".jpeg") || - url.toLowerCase().endsWith(".webp") || - url.toLowerCase().endsWith(".gif") || - url.toLowerCase().endsWith(".gifv") + if ( + url.toLowerCase().endsWith('.png') || + url.toLowerCase().endsWith('.jpg') || + url.toLowerCase().endsWith('.jpeg') || + url.toLowerCase().endsWith('.webp') || + url.toLowerCase().endsWith('.gif') || + url.toLowerCase().endsWith('.gifv') ) { - return `

` + return `

`; } - if (url.toLowerCase().endsWith("mp4") || url.toLowerCase().endsWith("mov")) { + if ( + url.toLowerCase().endsWith('mp4') || + url.toLowerCase().endsWith('mov') + ) { return `

`; } if (isYoutubeVideo(url)) { // kinda hacky but works - if (url.includes("youtu.be")) { - url = url.replace("youtu.be/", "youtube.com/watch?v=") + if (url.includes('youtu.be')) { + url = url.replace('youtu.be/', 'youtube.com/watch?v='); } - url = url.replace("watch?v=", "embed/") + url = url.replace('watch?v=', 'embed/'); return `

`; } return `

${url}

`; @@ -392,12 +446,13 @@ export class Content { encodeNoteAsEvent(note: string): string { let decodedNote = nip19.decode(note).data.toString(); - let eventP: nip19.EventPointer = {id: decodedNote} + let eventP: nip19.EventPointer = { id: decodedNote }; return nip19.neventEncode(eventP); } npubFromNProfile(nprofile: string): string { - const decodedNProfile: nip19.ProfilePointer = nip19.decode(nprofile).data as nip19.ProfilePointer; + const decodedNProfile: nip19.ProfilePointer = nip19.decode(nprofile) + .data as nip19.ProfilePointer; return nip19.npubEncode(decodedNProfile.pubkey); } @@ -405,40 +460,66 @@ export class Content { if (!this.hasEventPointer(content)) { return content; } - let matches = content.match(/nostr:[a-z0-9]+/gm) || [] + let matches = content.match(/nostr:[a-z0-9]+/gm) || []; for (let m in matches) { - let match = matches[m] + let match = matches[m]; try { - if (match.startsWith("nostr:npub")) { - let npub = match.substring(6) - let username = this.getUsername(npub) - let textWrap: TextWrap = {text: this.ellipsis(username), npub: npub, cssClass: "user-at"} - let htmlSpan = this.wrapTextInSpan(textWrap) + if (match.startsWith('nostr:npub')) { + let npub = match.substring(6); + let username = this.getUsername(npub); + let textWrap: TextWrap = { + text: this.ellipsis(username), + npub: npub, + cssClass: 'user-at', + }; + let htmlSpan = this.wrapTextInSpan(textWrap); content = content.replace(match, htmlSpan); } - if (match.startsWith("nostr:nevent")) { - let nevent = match.substring(6) - let textWrap: TextWrap = {text: this.ellipsis(nevent), nevent: nevent} - content = content.replace(match, this.wrapTextInSpan(textWrap)); + if (match.startsWith('nostr:nevent')) { + let nevent = match.substring(6); + let textWrap: TextWrap = { + text: this.ellipsis(nevent), + nevent: nevent, + }; + content = content.replace( + match, + this.wrapTextInSpan(textWrap) + ); } - if (match.startsWith("nostr:note")) { + if (match.startsWith('nostr:note')) { let note = match.substring(6); - let textWrap: TextWrap = {text: this.ellipsis(note), nevent: this.encodeNoteAsEvent(note)} - content = content.replace(match, this.wrapTextInSpan(textWrap)); + let textWrap: TextWrap = { + text: this.ellipsis(note), + nevent: this.encodeNoteAsEvent(note), + }; + content = content.replace( + match, + this.wrapTextInSpan(textWrap) + ); } - if (match.startsWith("nostr:nprofile")) { + if (match.startsWith('nostr:nprofile')) { const nprofile = match.substring(6); const npub = this.npubFromNProfile(nprofile); - let textWrap: TextWrap = {text: this.ellipsis(npub), npub: npub, cssClass: "user-at"} - content = content.replace(match, this.wrapTextInSpan(textWrap)); + let textWrap: TextWrap = { + text: this.ellipsis(npub), + npub: npub, + cssClass: 'user-at', + }; + content = content.replace( + match, + this.wrapTextInSpan(textWrap) + ); } - if (match.startsWith("nostr:naddr")) { + if (match.startsWith('nostr:naddr')) { // these are editable posts i think means long form // so we will link to habla.news for now // https://habla.news/a/naddr1qqxnzdesxg6rzdp4xu6nzwpnqgsf03c2gsmx5ef4c9zmxvlew04gdh7u94afnknp33qvv3c94kvwxgsrqsqqqa280a30ar const naddr = match.substring(6); //let textWrap: TextWrap = {text: this.ellipsis(naddr), nevent: this.encodeNAddrAsEvent(naddr)} - content = content.replace(naddr, `https://habla.news/a/${naddr}`) + content = content.replace( + naddr, + `https://habla.news/a/${naddr}` + ); content = this.linkify(content); } } catch (e) { @@ -449,8 +530,6 @@ export class Content { } } - - export class Post { kind: number; content: string; @@ -460,9 +539,9 @@ export class Post { createdAt: number; nip10Result: NIP10Result; date: Date; - fromNow: string = ""; - username: string = ""; - picture: string = ""; + fromNow: string = ''; + username: string = ''; + picture: string = ''; replyingTo: string[] = []; mentions: string[] = []; nostrNoteId: string; @@ -472,36 +551,72 @@ export class Post { isAReply: boolean = false; repostingPubkey: string; likedByMe: boolean = false; - constructor(kind: number, pubkey: string, content: string, noteId: string, createdAt: number, nip10Result: NIP10Result, repostingPubkey: string) { + likers: any; + shareCount: any; + commentCount: any; + comments: any; + id: any; + + constructor( + kind: number, + pubkey: string, + content: string, + noteId: string, + createdAt: number, + nip10Result: NIP10Result, + repostingPubkey: string, + private metadataService: MetadataService // Inject MetadataService + ) { this.kind = kind; this.pubkey = pubkey; this.npub = nip19.npubEncode(this.pubkey); - this.noteId = noteId + this.noteId = noteId; this.nip10Result = nip10Result; this.createdAt = createdAt; - this.date = new Date(this.createdAt*1000); - this.setFromNow() - this.setUsername(this.pubkey); - this.setPicture(this.pubkey); - this.content = new Content(kind, content, nip10Result).getParsedContent(); + this.date = new Date(this.createdAt * 1000); + this.setFromNow(); + this.content = new Content( + kind, + content, + nip10Result + ).getParsedContent(); this.nostrNoteId = nip19.noteEncode(this.noteId); - this.nostrEventId = nip19.neventEncode({id: this.noteId}); + this.nostrEventId = nip19.neventEncode({ id: this.noteId }); this.replyCount = 0; this.likeCount = 0; this.setIsAReply(); this.repostingPubkey = repostingPubkey; - } - - getAllTags(): string[][] { - return [["e", this.noteId], ["p", this.pubkey]] - } - setUsername(pubkey: string): void { - this.username = localStorage.getItem(`${pubkey}_name`) || this.npub; + // Fetch username and picture using the MetadataService + this.setUsernameAndPicture(pubkey); } - setPicture(pubkey: string): void { - this.picture = localStorage.getItem(`${pubkey}_img`) || "https://axiumradonmitigations.com/wp-content/uploads/2015/01/icon-user-default.png"; + getAllTags(): string[][] { + return [ + ['e', this.noteId], + ['p', this.pubkey], + ]; + } + + // Fetch metadata for the username and picture + async setUsernameAndPicture(pubkey: string): Promise { + try { + const metadata = + await this.metadataService.fetchMetadataWithCache(pubkey); + if (metadata) { + this.username = metadata.name || this.npub; + this.picture = + metadata.picture || + '/images/avatars/avatar-placeholder.png'; + } else { + this.username = this.npub; + this.picture = '/images/avatars/avatar-placeholder.png'; + } + } catch (error) { + console.error('Error fetching user metadata:', error); + this.username = this.npub; // Default to npub if there's an error + this.picture = '/images/avatars/avatar-placeholder.png'; // Default to a placeholder image + } } setReplyCount(count: number): void { @@ -513,11 +628,11 @@ export class Post { } setFromNow(): void { - this.fromNow = dayjs(this.date).fromNow() + this.fromNow = dayjs(this.date).fromNow(); } setReplyingTo(): void { - //this.replyingTo = this.nip10Result.profiles; + // Logic to set replying to information based on metadata } setPostLikedByMe(val: boolean): void { diff --git a/src/app/types/user.ts b/src/app/types/user.ts index e58bbf66..13902eeb 100644 --- a/src/app/types/user.ts +++ b/src/app/types/user.ts @@ -1,5 +1,5 @@ -import { nip19 } from 'nostr-tools'; import { Content } from 'app/types/post'; +import { nip19 } from 'nostr-tools'; // kind 0 content - nostr export interface Kind0Content { @@ -35,11 +35,10 @@ export interface DBUser { } export interface SearchUser { - pubkey: string, - picture: string + pubkey: string; + picture: string; } - export class BaseUser { pubkey: string; name: string; @@ -49,7 +48,6 @@ export class BaseUser { } } - export function dbUserToUser(dbUser: DBUser): User { const kind0Content: Kind0Content = { name: dbUser.name, @@ -61,62 +59,79 @@ export function dbUserToUser(dbUser: DBUser): User { banner: dbUser.banner, lud06: dbUser.lud06, lud16: dbUser.lud16, - nip05: dbUser.nip05 - } - return new User(kind0Content, Number(dbUser.createdAt), dbUser.pubkey, dbUser.following); + nip05: dbUser.nip05, + }; + return new User( + kind0Content, + Number(dbUser.createdAt), + dbUser.pubkey, + dbUser.following + ); } /* Preprocesses a kind0 message into a User class to be nicely accessible */ export class User { - name: string = ""; - username: string = ""; - displayName: string = ""; - website: string = ""; - about: string = ""; + name: string = ''; + username: string = ''; + displayName: string = ''; + website: string = ''; + about: string = ''; aboutHTML: string; - picture: string = ""; - banner: string = ""; - lud06: string = ""; - lud16: string = ""; - nip05: string = ""; + picture: string = ''; + banner: string = ''; + lud06: string = ''; + lud16: string = ''; + nip05: string = ''; pubkey: string; npub: string; createdAt: number; apiKey: string; following: boolean = false; - constructor(kind0: Kind0Content, createdAt: number, pubkey: string, following?: boolean) { + constructor( + kind0: Kind0Content, + createdAt: number, + pubkey: string, + following?: boolean + ) { this.pubkey = pubkey; this.npub = nip19.npubEncode(this.pubkey); - this.name = kind0.name || ""; - this.username = kind0.username || ""; - this.displayName = kind0.displayName || this.name || this.username || this.npub; - this.website = this.getClickableWebsite(kind0.website || ""); + this.name = kind0.name || ''; + this.username = kind0.username || ''; + this.displayName = + kind0.displayName || this.name || this.username || this.npub; + this.website = this.getClickableWebsite(kind0.website || ''); const fake = { reply: undefined, root: undefined, mentions: [], - profiles: [] - } - this.about = kind0.about || ""; - this.aboutHTML = new Content(1, kind0.about || "", fake, true).getParsedContent(); - this.picture = kind0.picture || "https://axiumradonmitigations.com/wp-content/uploads/2015/01/icon-user-default.png"; - this.banner = kind0.banner || ""; - this.lud06 = kind0.lud06 || ""; - this.lud16 = kind0.lud16 || ""; - this.nip05 = kind0.nip05 || ""; + profiles: [], + }; + this.about = kind0.about || ''; + this.aboutHTML = new Content( + 1, + kind0.about || '', + fake, + true + ).getParsedContent(); + this.picture = + kind0.picture || '/images/avatars/avatar-placeholder.png'; + this.banner = kind0.banner || ''; + this.lud06 = kind0.lud06 || ''; + this.lud16 = kind0.lud16 || ''; + this.nip05 = kind0.nip05 || ''; this.createdAt = createdAt; - this.cachePubkeyDisplayName() - this.apiKey = "LIVDSRZULELA" // TODO; + this.cachePubkeyDisplayName(); + this.apiKey = 'LIVDSRZULELA'; // TODO; if (following) { this.setFollowing(true); } } getClickableWebsite(link: string) { - if (link === "") return link; - if (link.startsWith("http://") || link.startsWith("https://")) { + if (link === '') return link; + if (link.startsWith('http://') || link.startsWith('https://')) { return link; } return `http://${link}`; @@ -131,6 +146,6 @@ export class User { } setFollowing(following: boolean) { - this.following = following + this.following = following; } } diff --git a/src/app/types/webln.ts b/src/app/types/webln.ts index 26c57f4d..c2dcc2b2 100644 --- a/src/app/types/webln.ts +++ b/src/app/types/webln.ts @@ -2,8 +2,8 @@ interface SendPaymentResponse { paymentHash?: string; preimage: string; route?: { - total_amt: number; - total_fees: number; + total_amt: number; + total_fees: number; }; } @@ -21,9 +21,9 @@ interface RequestInvoiceResponse { interface GetInfoResponse { node: { - alias: string; - pubkey: string; - color?: string; + alias: string; + pubkey: string; + color?: string; }; } @@ -51,4 +51,4 @@ export interface WebLN { // timestamp: number; // preimage?: string; // state: WalletInvoiceState; -// } \ No newline at end of file +// } diff --git a/src/index.html b/src/index.html index eea07e0e..97d48c55 100644 --- a/src/index.html +++ b/src/index.html @@ -1,48 +1,60 @@ - - - Angor Hub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Angor logo -
-
-
-
-
-
- - - - - + + Angor Hub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Angor logo +
+
+
+
+
+
+ + + + diff --git a/src/main.ts b/src/main.ts index 17447a5d..c2fda5c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; -bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); diff --git a/src/styles/emoji.scss b/src/styles/emoji.scss index 2269340c..09ade9b3 100644 --- a/src/styles/emoji.scss +++ b/src/styles/emoji.scss @@ -1,110 +1,114 @@ .emoji-mart, .emoji-mart * { - box-sizing: border-box; - line-height: 1.15; + box-sizing: border-box; + line-height: 1.15; } .emoji-mart { - font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; - font-size: 16px; - display: inline-block; - color: #222427; - border: 1px solid #d9d9d9; - border-radius: 15px; - background: #fff; + font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif; + font-size: 16px; + display: inline-block; + color: #222427; + border: 1px solid #d9d9d9; + border-radius: 15px; + background: #fff; } .emoji-mart .emoji-mart-emoji { - padding: 6px; + padding: 6px; } .emoji-mart-bar { - border: 0 solid #d9d9d9; + border: 0 solid #d9d9d9; } .emoji-mart-bar:first-child { - border-bottom-width: 1px; - border-top-left-radius: 5px; - border-top-right-radius: 5px; + border-bottom-width: 1px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; } .emoji-mart-bar:last-child { - border-top-width: 1px; - border-bottom-left-radius: 5px; - border-bottom-right-radius: 5px; + border-top-width: 1px; + border-bottom-left-radius: 5px; + border-bottom-right-radius: 5px; } .emoji-mart-anchors { - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 0 6px; - line-height: 0; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0 6px; + line-height: 0; } .emoji-mart-anchor { - position: relative; - display: block; - flex: 1 1 auto; - color: #858585; - text-align: center; - padding: 12px 4px; - overflow: hidden; - transition: color .1s ease-out; - margin: 0; - box-shadow: none; - background: none; - border: none; -} -.emoji-mart-anchor:focus { outline: 0 } + position: relative; + display: block; + flex: 1 1 auto; + color: #858585; + text-align: center; + padding: 12px 4px; + overflow: hidden; + transition: color 0.1s ease-out; + margin: 0; + box-shadow: none; + background: none; + border: none; +} +.emoji-mart-anchor:focus { + outline: 0; +} .emoji-mart-anchor:hover, .emoji-mart-anchor:focus, .emoji-mart-anchor-selected { - color: #464646; + color: #464646; } .emoji-mart-anchor-selected .emoji-mart-anchor-bar { - bottom: 0; + bottom: 0; } .emoji-mart-anchor-bar { - position: absolute; - bottom: -3px; left: 0; - width: 100%; height: 3px; - background-color: #464646; + position: absolute; + bottom: -3px; + left: 0; + width: 100%; + height: 3px; + background-color: #464646; } .emoji-mart-anchors i { - display: inline-block; - width: 100%; - max-width: 22px; + display: inline-block; + width: 100%; + max-width: 22px; } .emoji-mart-anchors svg, .emoji-mart-anchors img { - fill: currentColor; - height: 18px; + fill: currentColor; + height: 18px; } .emoji-mart-scroll { - overflow-y: scroll; - height: 270px; - padding: 0 6px 6px 6px; - will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */ + overflow-y: scroll; + height: 270px; + padding: 0 6px 6px 6px; + will-change: transform; /* avoids "repaints on scroll" in mobile Chrome */ } .emoji-mart-search { - margin-top: 6px; - padding: 0 6px; - position: relative; + margin-top: 6px; + padding: 0 6px; + position: relative; } .emoji-mart-search input { - font-size: 16px; - display: block; - width: 100%; - padding: 5px 25px 6px 10px; - border-radius: 5px; - border: 1px solid #d9d9d9; - outline: 0; + font-size: 16px; + display: block; + width: 100%; + padding: 5px 25px 6px 10px; + border-radius: 5px; + border: 1px solid #d9d9d9; + outline: 0; } .emoji-mart-search input, @@ -112,340 +116,372 @@ .emoji-mart-search input::-webkit-search-cancel-button, .emoji-mart-search input::-webkit-search-results-button, .emoji-mart-search input::-webkit-search-results-decoration { - -webkit-appearance: none; + -webkit-appearance: none; } .emoji-mart-search-icon { - position: absolute; - top: 6px; - right: 11px; - z-index: 2; - padding: 2px 5px 1px; - border: none; - background: none; + position: absolute; + top: 6px; + right: 11px; + z-index: 2; + padding: 2px 5px 1px; + border: none; + background: none; } .emoji-mart-category .emoji-mart-emoji span { - z-index: 1; - position: relative; - text-align: center; - cursor: default; + z-index: 1; + position: relative; + text-align: center; + cursor: default; } .emoji-mart-category .emoji-mart-emoji:hover:before { - z-index: 0; - content: ""; - position: absolute; - top: 0; left: 0; - width: 100%; height: 100%; - background-color: #f4f4f4; - border-radius: 100%; + z-index: 0; + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #f4f4f4; + border-radius: 100%; } .emoji-mart-category-label { - z-index: 2; - position: relative; - position: -webkit-sticky; - position: sticky; - top: 0; + z-index: 2; + position: relative; + position: -webkit-sticky; + position: sticky; + top: 0; } .emoji-mart-category-label span { - display: block; - width: 100%; - font-weight: 500; - padding: 5px 6px; - background-color: #fff; - background-color: rgba(255, 255, 255, .95); + display: block; + width: 100%; + font-weight: 500; + padding: 5px 6px; + background-color: #fff; + background-color: rgba(255, 255, 255, 0.95); } .emoji-mart-category-list { - margin: 0; - padding: 0; + margin: 0; + padding: 0; } .emoji-mart-category-list li { - list-style: none; - margin: 0; - padding: 0; - display: inline-block; + list-style: none; + margin: 0; + padding: 0; + display: inline-block; } .emoji-mart-emoji { - position: relative; - display: inline-block; - font-size: 0; - margin: 0; - padding: 0; - border: none; - background: none; - box-shadow: none; + position: relative; + display: inline-block; + font-size: 0; + margin: 0; + padding: 0; + border: none; + background: none; + box-shadow: none; } .emoji-mart-emoji-native { - font-family: "Segoe UI Emoji", "Segoe UI Symbol", "Segoe UI", "Apple Color Emoji", "Twemoji Mozilla", "Noto Color Emoji", "Android Emoji"; + font-family: 'Segoe UI Emoji', 'Segoe UI Symbol', 'Segoe UI', + 'Apple Color Emoji', 'Twemoji Mozilla', 'Noto Color Emoji', + 'Android Emoji'; } .emoji-mart-no-results { - font-size: 14px; - text-align: center; - padding-top: 70px; - color: #858585; + font-size: 14px; + text-align: center; + padding-top: 70px; + color: #858585; } .emoji-mart-no-results .emoji-mart-category-label { - display: none; + display: none; } .emoji-mart-no-results .emoji-mart-no-results-label { - margin-top: .2em; + margin-top: 0.2em; } .emoji-mart-no-results .emoji-mart-emoji:hover:before { - content: none; + content: none; } .emoji-mart-preview { - position: relative; - height: 70px; + position: relative; + height: 70px; } .emoji-mart-preview-emoji, .emoji-mart-preview-data, .emoji-mart-preview-skins { - position: absolute; - top: 50%; - transform: translateY(-50%); + position: absolute; + top: 50%; + transform: translateY(-50%); } .emoji-mart-preview-emoji { - left: 12px; + left: 12px; } .emoji-mart-preview-data { - left: 68px; right: 12px; - word-break: break-all; + left: 68px; + right: 12px; + word-break: break-all; } .emoji-mart-preview-skins { - right: 30px; - text-align: right; + right: 30px; + text-align: right; } .emoji-mart-preview-skins.custom { - right: 10px; - text-align: right; + right: 10px; + text-align: right; } .emoji-mart-preview-name { - font-size: 14px; + font-size: 14px; } .emoji-mart-preview-shortname { - font-size: 12px; - color: #888; + font-size: 12px; + color: #888; } .emoji-mart-preview-shortname + .emoji-mart-preview-shortname, .emoji-mart-preview-shortname + .emoji-mart-preview-emoticon, .emoji-mart-preview-emoticon + .emoji-mart-preview-emoticon { - margin-left: .5em; + margin-left: 0.5em; } .emoji-mart-preview-emoticon { - font-size: 11px; - color: #bbb; + font-size: 11px; + color: #bbb; } .emoji-mart-title span { - display: inline-block; - vertical-align: middle; + display: inline-block; + vertical-align: middle; } .emoji-mart-title .emoji-mart-emoji { - padding: 0; + padding: 0; } .emoji-mart-title-label { - color: #999A9C; - font-size: 26px; - font-weight: 300; + color: #999a9c; + font-size: 26px; + font-weight: 300; } .emoji-mart-skin-swatches { - font-size: 0; - padding: 2px 0; - border: 1px solid #d9d9d9; - border-radius: 15px; - background-color: #fff; + font-size: 0; + padding: 2px 0; + border: 1px solid #d9d9d9; + border-radius: 15px; + background-color: #fff; } .emoji-mart-skin-swatches.custom { - font-size: 0; - border: none; - background-color: #fff; + font-size: 0; + border: none; + background-color: #fff; } .emoji-mart-skin-swatches.opened .emoji-mart-skin-swatch { - width: 16px; - padding: 0 2px; + width: 16px; + padding: 0 2px; } .emoji-mart-skin-swatches.opened .emoji-mart-skin-swatch.selected:after { - opacity: .75; + opacity: 0.75; } .emoji-mart-skin-swatch { - display: inline-block; - width: 0; - vertical-align: middle; - transition-property: width, padding; - transition-duration: .125s; - transition-timing-function: ease-out; + display: inline-block; + width: 0; + vertical-align: middle; + transition-property: width, padding; + transition-duration: 0.125s; + transition-timing-function: ease-out; } -.emoji-mart-skin-swatch:nth-child(1) { transition-delay: 0s } -.emoji-mart-skin-swatch:nth-child(2) { transition-delay: .03s } -.emoji-mart-skin-swatch:nth-child(3) { transition-delay: .06s } -.emoji-mart-skin-swatch:nth-child(4) { transition-delay: .09s } -.emoji-mart-skin-swatch:nth-child(5) { transition-delay: .12s } -.emoji-mart-skin-swatch:nth-child(6) { transition-delay: .15s } +.emoji-mart-skin-swatch:nth-child(1) { + transition-delay: 0s; +} +.emoji-mart-skin-swatch:nth-child(2) { + transition-delay: 0.03s; +} +.emoji-mart-skin-swatch:nth-child(3) { + transition-delay: 0.06s; +} +.emoji-mart-skin-swatch:nth-child(4) { + transition-delay: 0.09s; +} +.emoji-mart-skin-swatch:nth-child(5) { + transition-delay: 0.12s; +} +.emoji-mart-skin-swatch:nth-child(6) { + transition-delay: 0.15s; +} .emoji-mart-skin-swatch.selected { - position: relative; - width: 16px; - padding: 0 2px; + position: relative; + width: 16px; + padding: 0 2px; } .emoji-mart-skin-swatch.selected:after { - content: ""; - position: absolute; - top: 50%; left: 50%; - width: 4px; height: 4px; - margin: -2px 0 0 -2px; - background-color: #fff; - border-radius: 100%; - pointer-events: none; - opacity: 0; - transition: opacity .2s ease-out; + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 4px; + height: 4px; + margin: -2px 0 0 -2px; + background-color: #fff; + border-radius: 100%; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-out; } .emoji-mart-skin-swatch.custom { - display: inline-block; - width: 0; - height: 38px; - overflow: hidden; - vertical-align: middle; - transition-property: width, height; - transition-duration: .125s; - transition-timing-function: ease-out; - cursor: default; + display: inline-block; + width: 0; + height: 38px; + overflow: hidden; + vertical-align: middle; + transition-property: width, height; + transition-duration: 0.125s; + transition-timing-function: ease-out; + cursor: default; } .emoji-mart-skin-swatch.custom.selected { - position: relative; - width: 36px; - height: 38px; - padding: 0 2px 0 0; + position: relative; + width: 36px; + height: 38px; + padding: 0 2px 0 0; } .emoji-mart-skin-swatch.custom.selected:after { - content: ""; - width: 0; - height: 0; + content: ''; + width: 0; + height: 0; } .emoji-mart-skin-swatches.custom .emoji-mart-skin-swatch.custom:hover { - background-color: #f4f4f4; - border-radius: 10%; + background-color: #f4f4f4; + border-radius: 10%; } .emoji-mart-skin-swatches.custom.opened .emoji-mart-skin-swatch.custom { - width: 36px; - height: 38px; - padding: 0 2px 0 0; + width: 36px; + height: 38px; + padding: 0 2px 0 0; } -.emoji-mart-skin-swatches.custom.opened .emoji-mart-skin-swatch.custom.selected:after { - opacity: .75; +.emoji-mart-skin-swatches.custom.opened + .emoji-mart-skin-swatch.custom.selected:after { + opacity: 0.75; } .emoji-mart-skin-text.opened { - display: inline-block; - vertical-align: middle; - text-align: left; - color: #888; - font-size: 11px; - padding: 5px 2px; - width: 95px; - height: 40px; - border-radius: 10%; - background-color: #fff; + display: inline-block; + vertical-align: middle; + text-align: left; + color: #888; + font-size: 11px; + padding: 5px 2px; + width: 95px; + height: 40px; + border-radius: 10%; + background-color: #fff; } .emoji-mart-skin { - display: inline-block; - width: 100%; - padding-top: 100%; - max-width: 12px; - border-radius: 100%; + display: inline-block; + width: 100%; + padding-top: 100%; + max-width: 12px; + border-radius: 100%; } -.emoji-mart-skin-tone-1 { background-color: #ffc93a } -.emoji-mart-skin-tone-2 { background-color: #fadcbc } -.emoji-mart-skin-tone-3 { background-color: #e0bb95 } -.emoji-mart-skin-tone-4 { background-color: #bf8f68 } -.emoji-mart-skin-tone-5 { background-color: #9b643d } -.emoji-mart-skin-tone-6 { background-color: #594539 } +.emoji-mart-skin-tone-1 { + background-color: #ffc93a; +} +.emoji-mart-skin-tone-2 { + background-color: #fadcbc; +} +.emoji-mart-skin-tone-3 { + background-color: #e0bb95; +} +.emoji-mart-skin-tone-4 { + background-color: #bf8f68; +} +.emoji-mart-skin-tone-5 { + background-color: #9b643d; +} +.emoji-mart-skin-tone-6 { + background-color: #594539; +} .emoji-mart-sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; } /* * Dark mode styles */ - .emoji-mart-dark { +.emoji-mart-dark { color: #e5eef0; /* gray-50 */ border-color: #083b46; /* gray-500 */ border-width: 2px; border-radius: 15px; background-color: #022229; /* gray-900 */ - } +} - .emoji-mart-dark .emoji-mart-bar { +.emoji-mart-dark .emoji-mart-bar { border-color: #083b46; /* gray-500 */ - } +} - .emoji-mart-dark .emoji-mart-search input { +.emoji-mart-dark .emoji-mart-search input { color: #e5eef0; /* gray-50 */ border-color: #083b46; /* gray-500 */ background-color: #07343e; /* gray-600 */ - } - .emoji-mart-dark .emoji-mart-search-icon svg { +} +.emoji-mart-dark .emoji-mart-search-icon svg { fill: #e5eef0; /* gray-50 */ - } +} - .emoji-mart-dark .emoji-mart-category .emoji-mart-emoji:hover:before { +.emoji-mart-dark .emoji-mart-category .emoji-mart-emoji:hover:before { background-color: #052b33; /* gray-700 */ - } +} - .emoji-mart-dark .emoji-mart-category-label span { +.emoji-mart-dark .emoji-mart-category-label span { background-color: #032128; /* gray-800 */ color: #e5eef0; /* gray-50 */ - } +} - .emoji-mart-dark .emoji-mart-skin-swatches { +.emoji-mart-dark .emoji-mart-skin-swatches { border-color: #083b46; /* gray-500 */ background-color: #022229; /* gray-900 */ - } +} - .emoji-mart-dark .emoji-mart-anchor:hover, - .emoji-mart-dark .emoji-mart-anchor:focus, - .emoji-mart-dark .emoji-mart-anchor-selected { +.emoji-mart-dark .emoji-mart-anchor:hover, +.emoji-mart-dark .emoji-mart-anchor:focus, +.emoji-mart-dark .emoji-mart-anchor-selected { color: #9bbac3; /* gray-200 */ - } +} diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 39d0353e..ee198815 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -11,8 +11,11 @@ body { position: absolute; bottom: 60px; left: 0; - z-index: 1000; - } - input, textarea, select, button { + z-index: 100000; +} +input, +textarea, +select, +button { font-size: 16px; } diff --git a/src/webpack.config.js b/src/webpack.config.js index b07179e6..2c0d19c1 100644 --- a/src/webpack.config.js +++ b/src/webpack.config.js @@ -1,21 +1,24 @@ const webpack = require('webpack'); module.exports = { - resolve: { - fallback: { - crypto: require.resolve('crypto-browserify'), - stream: require.resolve('stream-browserify'), - // url: require.resolve('url/'), - url: false, + resolve: { + fallback: { + crypto: require.resolve('crypto-browserify'), + stream: require.resolve('stream-browserify'), + // url: require.resolve('url/'), + url: false, + }, }, - }, - plugins: [ - new webpack.ProvidePlugin({ - process: 'process/browser', - }), - new webpack.NormalModuleReplacementPlugin(/node:crypto/, require.resolve('crypto-browserify')), - new webpack.DefinePlugin({ - global: 'globalThis', - }), - ], + plugins: [ + new webpack.ProvidePlugin({ + process: 'process/browser', + }), + new webpack.NormalModuleReplacementPlugin( + /node:crypto/, + require.resolve('crypto-browserify') + ), + new webpack.DefinePlugin({ + global: 'globalThis', + }), + ], };