- About tipping
+ About zapping
- Also known as Zap in other Nostr client.
-
-
- Lume doesn't take any commission or platform fees when you tip
+ Lume doesn't take any commission or platform fees when you zap
someone.
diff --git a/src/app/users/components/modal.tsx b/src/app/users/components/modal.tsx
index f6c38efe..454805b8 100644
--- a/src/app/users/components/modal.tsx
+++ b/src/app/users/components/modal.tsx
@@ -1,18 +1,22 @@
-import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk';
+import { NDKEvent, NDKKind, NDKUserProfile } from '@nostr-dev-kit/ndk';
import * as Dialog from '@radix-ui/react-dialog';
import { useQueryClient } from '@tanstack/react-query';
+import { message, open } from '@tauri-apps/plugin-dialog';
+import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { fetch } from '@tauri-apps/plugin-http';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
+import { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider';
-import { AvatarUploader } from '@shared/avatarUploader';
-import { BannerUploader } from '@shared/bannerUploader';
-import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
-import { Image } from '@shared/image';
-
-import { useNostr } from '@utils/hooks/useNostr';
+import {
+ CancelIcon,
+ CheckCircleIcon,
+ LoaderIcon,
+ PlusIcon,
+ UnverifiedIcon,
+} from '@shared/icons';
interface NIP05 {
names: {
@@ -25,12 +29,12 @@ export function EditProfileModal() {
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
- const [picture, setPicture] = useState('https://void.cat/d/5VKmKyuHyxrNMf9bWSVPih');
- const [banner, setBanner] = useState(null);
+ const [picture, setPicture] = useState('');
+ const [banner, setBanner] = useState('');
const [nip05, setNIP05] = useState({ verified: false, text: '' });
const { db } = useStorage();
- const { publish } = useNostr();
+ const { ndk } = useNDK();
const {
register,
handleSubmit,
@@ -75,12 +79,106 @@ export function EditProfileModal() {
return false;
};
+ const uploadAvatar = async () => {
+ try {
+ // start loading
+ setLoading(true);
+
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: 'Image',
+ extensions: ['png', 'jpeg', 'jpg', 'gif'],
+ },
+ ],
+ });
+
+ if (!selected) {
+ setLoading(false);
+ return;
+ }
+
+ const file = await readBinaryFile(selected.path);
+ const blob = new Blob([file]);
+
+ const data = new FormData();
+ data.append('fileToUpload', blob);
+ data.append('submit', 'Upload Image');
+
+ const res = await fetch('https://nostr.build/api/v2/upload/files', {
+ method: 'POST',
+ body: data,
+ });
+
+ if (res.ok) {
+ const json = await res.json();
+ const content = json.data[0];
+
+ setPicture(content.url);
+
+ // stop loading
+ setLoading(false);
+ }
+ } catch (e) {
+ // stop loading
+ setLoading(false);
+ await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
+ }
+ };
+
+ const uploadBanner = async () => {
+ try {
+ // start loading
+ setLoading(true);
+
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: 'Image',
+ extensions: ['png', 'jpeg', 'jpg', 'gif'],
+ },
+ ],
+ });
+
+ if (!selected) {
+ setLoading(false);
+ return;
+ }
+
+ const file = await readBinaryFile(selected.path);
+ const blob = new Blob([file]);
+
+ const data = new FormData();
+ data.append('fileToUpload', blob);
+ data.append('submit', 'Upload Image');
+
+ const res = await fetch('https://nostr.build/api/v2/upload/files', {
+ method: 'POST',
+ body: data,
+ });
+
+ if (res.ok) {
+ const json = await res.json();
+ const content = json.data[0];
+
+ setBanner(content.url);
+
+ // stop loading
+ setLoading(false);
+ }
+ } catch (e) {
+ // stop loading
+ setLoading(false);
+ await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
+ }
+ };
+
const onSubmit = async (data: NDKUserProfile) => {
// start loading
setLoading(true);
- let event: NDKEvent;
-
const content = {
...data,
username: data.name,
@@ -89,14 +187,14 @@ export function EditProfileModal() {
image: data.picture,
};
+ const event = new NDKEvent(ndk);
+ event.kind = NDKKind.Metadata;
+ event.tags = [];
+
if (data.nip05) {
const nip05IsVerified = await verifyNIP05(data.nip05);
if (nip05IsVerified) {
- event = await publish({
- content: JSON.stringify({ ...content, nip05: data.nip05 }),
- kind: 0,
- tags: [],
- });
+ event.content = JSON.stringify({ ...content, nip05: data.nip05 });
} else {
setNIP05((prev) => ({ ...prev, verified: false }));
setError('nip05', {
@@ -105,14 +203,12 @@ export function EditProfileModal() {
});
}
} else {
- event = await publish({
- content: JSON.stringify(content),
- kind: 0,
- tags: [],
- });
+ event.content = JSON.stringify(content);
}
- if (event.id) {
+ const publishedRelays = await event.publish();
+
+ if (publishedRelays) {
// invalid cache
queryClient.invalidateQueries(['user', db.account.pubkey]);
// reset form
@@ -144,7 +240,7 @@ export function EditProfileModal() {
-
+
@@ -173,18 +269,30 @@ export function EditProfileModal() {
)}
-
+
uploadBanner()}
+ className="inline-flex h-full w-full items-center justify-center bg-black/50"
+ >
+
+
-
-
+
-
+
uploadAvatar()}
+ className="inline-flex h-full w-full items-center justify-center rounded-xl bg-black/50"
+ >
+
+
diff --git a/src/app/users/components/profile.tsx b/src/app/users/components/profile.tsx
index f1ff5f0c..a71824c7 100644
--- a/src/app/users/components/profile.tsx
+++ b/src/app/users/components/profile.tsx
@@ -50,15 +50,15 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
return (
<>
-
+
{user.banner ? (
) : (
-
+
)}
diff --git a/src/libs/ndk/cache.ts b/src/libs/ndk/cache.ts
new file mode 100644
index 00000000..782c0b1a
--- /dev/null
+++ b/src/libs/ndk/cache.ts
@@ -0,0 +1,455 @@
+import { NDKEvent, NDKRelay, profileFromEvent } from '@nostr-dev-kit/ndk';
+import type {
+ Hexpubkey,
+ NDKCacheAdapter,
+ NDKFilter,
+ NDKSubscription,
+ NDKUserProfile,
+} from '@nostr-dev-kit/ndk';
+import _debug from 'debug';
+import { matchFilter } from 'nostr-tools';
+import { LRUCache } from 'typescript-lru-cache';
+
+import { createDatabase, db } from './db';
+
+export { db } from './db';
+
+interface NDKCacheAdapterDexieOptions {
+ /**
+ * The name of the database to use
+ */
+ dbName?: string;
+
+ /**
+ * Debug instance to use for logging
+ */
+ debug?: debug.IDebugger;
+
+ /**
+ * The number of seconds to store events in Dexie (IndexedDB) before they expire
+ * Defaults to 3600 seconds (1 hour)
+ */
+ expirationTime?: number;
+
+ /**
+ * Number of profiles to keep in an LRU cache
+ */
+ profileCacheSize?: number | 'disabled';
+}
+
+export default class NDKCacheAdapterDexie implements NDKCacheAdapter {
+ public debug: debug.Debugger;
+ private expirationTime;
+ readonly locking;
+ public profiles?: LRUCache
;
+ private dirtyProfiles: Set = new Set();
+
+ constructor(opts: NDKCacheAdapterDexieOptions = {}) {
+ createDatabase(opts.dbName || 'ndk');
+ this.debug = opts.debug || _debug('ndk:dexie-adapter');
+ this.locking = true;
+ this.expirationTime = opts.expirationTime || 3600;
+
+ if (opts.profileCacheSize !== 'disabled') {
+ this.profiles = new LRUCache({
+ maxSize: opts.profileCacheSize || 100000,
+ });
+
+ setInterval(() => {
+ this.dumpProfiles();
+ }, 1000 * 10);
+ }
+ }
+
+ public async query(subscription: NDKSubscription): Promise {
+ Promise.allSettled(
+ subscription.filters.map((filter) => this.processFilter(filter, subscription))
+ );
+ }
+
+ public async fetchProfile(pubkey: Hexpubkey) {
+ if (!this.profiles) return null;
+
+ let profile = this.profiles.get(pubkey);
+
+ if (!profile) {
+ const user = await db.users.get({ pubkey });
+ if (user) {
+ profile = user.profile;
+ this.profiles.set(pubkey, profile);
+ }
+ }
+
+ return profile;
+ }
+
+ public saveProfile(pubkey: Hexpubkey, profile: NDKUserProfile) {
+ if (!this.profiles) return;
+
+ this.profiles.set(pubkey, profile);
+
+ this.dirtyProfiles.add(pubkey);
+ }
+
+ private async processFilter(
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const _filter = { ...filter };
+ delete _filter.limit;
+ const filterKeys = Object.keys(_filter || {}).sort();
+
+ try {
+ (await this.byKindAndAuthor(filterKeys, filter, subscription)) ||
+ (await this.byAuthors(filterKeys, filter, subscription)) ||
+ (await this.byKinds(filterKeys, filter, subscription)) ||
+ (await this.byIdsQuery(filterKeys, filter, subscription)) ||
+ (await this.byNip33Query(filterKeys, filter, subscription)) ||
+ (await this.byTagsAndOptionallyKinds(filterKeys, filter, subscription));
+ } catch (error) {
+ console.error(error);
+ }
+ }
+
+ public async setEvent(
+ event: NDKEvent,
+ _filter: NDKFilter,
+ relay?: NDKRelay
+ ): Promise {
+ if (event.kind === 0) {
+ if (!this.profiles) return;
+
+ const profile: NDKUserProfile = profileFromEvent(event);
+ this.profiles.set(event.pubkey, profile);
+ } else {
+ let addEvent = true;
+
+ if (event.isParamReplaceable()) {
+ const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
+ const existingEvent = await db.events.where({ id: replaceableId }).first();
+ if (
+ existingEvent &&
+ event.created_at &&
+ existingEvent.createdAt > event.created_at
+ ) {
+ addEvent = false;
+ }
+ }
+
+ if (addEvent) {
+ db.events.put({
+ id: event.tagId(),
+ pubkey: event.pubkey,
+ content: event.content,
+ kind: event.kind!,
+ createdAt: event.created_at!,
+ relay: relay?.url,
+ event: JSON.stringify(event.rawEvent()),
+ });
+
+ // Don't cache contact lists as tags since it's expensive
+ // and there is no use case for it
+ if (event.kind !== 3) {
+ event.tags.forEach((tag) => {
+ if (tag[0].length !== 1) return;
+
+ db.eventTags.put({
+ id: `${event.id}:${tag[0]}:${tag[1]}`,
+ eventId: event.id,
+ tag: tag[0],
+ value: tag[1],
+ tagValue: tag[0] + tag[1],
+ });
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * Searches by authors
+ */
+ private async byAuthors(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const f = ['authors'];
+ const hasAllKeys =
+ filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
+
+ let foundEvents = false;
+
+ if (hasAllKeys && filter.authors) {
+ for (const pubkey of filter.authors) {
+ const events = await db.events.where({ pubkey }).toArray();
+ for (const event of events) {
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ subscription.eventReceived(ndkEvent, relay, true);
+ foundEvents = true;
+ }
+ }
+ }
+ return foundEvents;
+ }
+
+ /**
+ * Searches by kinds
+ */
+ private async byKinds(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const f = ['kinds'];
+ const hasAllKeys =
+ filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
+
+ let foundEvents = false;
+
+ if (hasAllKeys && filter.kinds) {
+ for (const kind of filter.kinds) {
+ const events = await db.events.where({ kind }).toArray();
+ for (const event of events) {
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ subscription.eventReceived(ndkEvent, relay, true);
+ foundEvents = true;
+ }
+ }
+ }
+ return foundEvents;
+ }
+
+ /**
+ * Searches by ids
+ */
+ private async byIdsQuery(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const f = ['ids'];
+ const hasAllKeys =
+ filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
+
+ if (hasAllKeys && filter.ids) {
+ for (const id of filter.ids) {
+ const event = await db.events.where({ id }).first();
+ if (!event) continue;
+
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ subscription.eventReceived(ndkEvent, relay, true);
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Searches by NIP-33
+ */
+ private async byNip33Query(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const f = ['#d', 'authors', 'kinds'];
+ const hasAllKeys =
+ filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
+
+ if (hasAllKeys && filter.kinds && filter.authors) {
+ for (const kind of filter.kinds) {
+ const replaceableKind = kind >= 30000 && kind < 40000;
+
+ if (!replaceableKind) continue;
+
+ for (const author of filter.authors) {
+ for (const dTag of filter['#d']) {
+ const replaceableId = `${kind}:${author}:${dTag}`;
+ const event = await db.events.where({ id: replaceableId }).first();
+ if (!event) continue;
+
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ subscription.eventReceived(ndkEvent, relay, true);
+ }
+ }
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Searches by kind & author
+ */
+ private async byKindAndAuthor(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ const f = ['authors', 'kinds'];
+ const hasAllKeys =
+ filterKeys.length === f.length && f.every((k) => filterKeys.includes(k));
+ let foundEvents = false;
+
+ if (!hasAllKeys) return false;
+
+ if (filter.kinds && filter.authors) {
+ for (const kind of filter.kinds) {
+ for (const author of filter.authors) {
+ const events = await db.events.where({ kind, pubkey: author }).toArray();
+
+ for (const event of events) {
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ subscription.eventReceived(ndkEvent, relay, true);
+ foundEvents = true;
+ }
+ }
+ }
+ }
+ return foundEvents;
+ }
+
+ /**
+ * Searches by tags and optionally filters by tags
+ */
+ private async byTagsAndOptionallyKinds(
+ filterKeys: string[],
+ filter: NDKFilter,
+ subscription: NDKSubscription
+ ): Promise {
+ for (const filterKey of filterKeys) {
+ const isKind = filterKey === 'kinds';
+ const isTag = filterKey.startsWith('#') && filterKey.length === 2;
+
+ if (!isKind && !isTag) return false;
+ }
+
+ const events = await this.filterByTag(filterKeys, filter);
+ const kinds = filter.kinds as number[];
+
+ for (const event of events) {
+ if (!kinds?.includes(event.kind!)) continue;
+
+ subscription.eventReceived(event, undefined, true);
+ }
+
+ return false;
+ }
+
+ private async filterByTag(
+ filterKeys: string[],
+ filter: NDKFilter
+ ): Promise {
+ const retEvents: NDKEvent[] = [];
+
+ for (const filterKey of filterKeys) {
+ if (filterKey.length !== 2) continue;
+ const tag = filterKey.slice(1);
+ // const values = filter[filterKey] as string[];
+ const values: string[] = [];
+ for (const [key, value] of Object.entries(filter)) {
+ if (key === filterKey) values.push(value as string);
+ }
+
+ for (const value of values) {
+ const eventTags = await db.eventTags.where({ tagValue: tag + value }).toArray();
+ if (!eventTags.length) continue;
+
+ const eventIds = eventTags.map((t) => t.eventId);
+
+ const events = await db.events.where('id').anyOf(eventIds).toArray();
+ for (const event of events) {
+ let rawEvent;
+ try {
+ rawEvent = JSON.parse(event.event);
+
+ // Make sure all passed filters match the event
+ if (!matchFilter(filter, rawEvent)) continue;
+ } catch (e) {
+ console.log('failed to parse event', e);
+ continue;
+ }
+
+ const ndkEvent = new NDKEvent(undefined, rawEvent);
+ const relay = event.relay ? new NDKRelay(event.relay) : undefined;
+ ndkEvent.relay = relay;
+ retEvents.push(ndkEvent);
+ }
+ }
+ }
+
+ return retEvents;
+ }
+
+ private async dumpProfiles(): Promise {
+ const profiles = [];
+
+ if (!this.profiles) return;
+
+ for (const pubkey of this.dirtyProfiles) {
+ const profile = this.profiles.get(pubkey);
+
+ if (!profile) continue;
+
+ profiles.push({
+ pubkey,
+ profile,
+ createdAt: Date.now(),
+ });
+ }
+
+ if (profiles.length) {
+ await db.users.bulkPut(profiles);
+ }
+
+ this.dirtyProfiles.clear();
+ }
+}
diff --git a/src/shared/accounts/active.tsx b/src/shared/accounts/active.tsx
index a199a835..78fde01e 100644
--- a/src/shared/accounts/active.tsx
+++ b/src/shared/accounts/active.tsx
@@ -55,7 +55,7 @@ export function ActiveAccount() {
if (status === 'loading') {
return (
-
+
);
}
diff --git a/src/shared/avatarUploader.tsx b/src/shared/avatarUploader.tsx
index 5dbd74d7..f46f5369 100644
--- a/src/shared/avatarUploader.tsx
+++ b/src/shared/avatarUploader.tsx
@@ -1,31 +1,69 @@
+import { message, open } from '@tauri-apps/plugin-dialog';
+import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
-import { useNostr } from '@utils/hooks/useNostr';
-
export function AvatarUploader({
setPicture,
}: {
setPicture: Dispatch>;
}) {
- const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadAvatar = async () => {
- setLoading(true);
- const image = await upload(null);
- if (image.url) {
- setPicture(image.url);
+ try {
+ // start loading
+ setLoading(true);
+
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: 'Image',
+ extensions: ['png', 'jpeg', 'jpg', 'gif'],
+ },
+ ],
+ });
+
+ if (!selected) {
+ setLoading(false);
+ return;
+ }
+
+ const file = await readBinaryFile(selected.path);
+ const blob = new Blob([file]);
+
+ const data = new FormData();
+ data.append('fileToUpload', blob);
+ data.append('submit', 'Upload Image');
+
+ const res = await fetch('https://nostr.build/api/v2/upload/files', {
+ method: 'POST',
+ body: data,
+ });
+
+ if (res.ok) {
+ const json = await res.json();
+ const content = json.data[0];
+
+ setPicture(content.url);
+
+ // stop loading
+ setLoading(false);
+ }
+ } catch (e) {
+ // stop loading
+ setLoading(false);
+ await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
- setLoading(false);
};
return (
uploadAvatar()}
- className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
+ className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-100 px-1.5 py-1 text-sm font-medium text-blue-500 hover:border-blue-300 hover:bg-blue-200 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-500 dark:hover:border-blue-800 dark:hover:bg-blue-800"
>
{loading ? (
diff --git a/src/shared/bannerUploader.tsx b/src/shared/bannerUploader.tsx
index 83e3c329..84d88495 100644
--- a/src/shared/bannerUploader.tsx
+++ b/src/shared/bannerUploader.tsx
@@ -1,24 +1,62 @@
+import { message, open } from '@tauri-apps/plugin-dialog';
+import { readBinaryFile } from '@tauri-apps/plugin-fs';
import { Dispatch, SetStateAction, useState } from 'react';
import { LoaderIcon, PlusIcon } from '@shared/icons';
-import { useNostr } from '@utils/hooks/useNostr';
-
export function BannerUploader({
setBanner,
}: {
setBanner: Dispatch>;
}) {
- const { upload } = useNostr();
const [loading, setLoading] = useState(false);
const uploadBanner = async () => {
- setLoading(true);
- const image = await upload(null);
- if (image.url) {
- setBanner(image.url);
+ try {
+ // start loading
+ setLoading(true);
+
+ const selected = await open({
+ multiple: false,
+ filters: [
+ {
+ name: 'Image',
+ extensions: ['png', 'jpeg', 'jpg', 'gif'],
+ },
+ ],
+ });
+
+ if (!selected) {
+ setLoading(false);
+ return;
+ }
+
+ const file = await readBinaryFile(selected.path);
+ const blob = new Blob([file]);
+
+ const data = new FormData();
+ data.append('fileToUpload', blob);
+ data.append('submit', 'Upload Image');
+
+ const res = await fetch('https://nostr.build/api/v2/upload/files', {
+ method: 'POST',
+ body: data,
+ });
+
+ if (res.ok) {
+ const json = await res.json();
+ const content = json.data[0];
+
+ setBanner(content.url);
+
+ // stop loading
+ setLoading(false);
+ }
+ } catch (e) {
+ // stop loading
+ setLoading(false);
+ await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
}
- setLoading(false);
};
return (
diff --git a/src/shared/notes/actions/repost.tsx b/src/shared/notes/actions/repost.tsx
index c7640828..14a275be 100644
--- a/src/shared/notes/actions/repost.tsx
+++ b/src/shared/notes/actions/repost.tsx
@@ -67,7 +67,7 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
-
+
diff --git a/src/shared/notes/mentions/note.tsx b/src/shared/notes/mentions/note.tsx
index ed3a8379..493e8e13 100644
--- a/src/shared/notes/mentions/note.tsx
+++ b/src/shared/notes/mentions/note.tsx
@@ -1,7 +1,6 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { memo } from 'react';
-import { Link } from 'react-router-dom';
import { useStorage } from '@libs/storage/provider';
@@ -68,13 +67,6 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
-
- Lume cannot find this post with your current relays, but you can view it via
- njump.me.{' '}
-
- Learn more
-
-
@@ -87,10 +79,10 @@ export const MentionNote = memo(function MentionNote({ id }: { id: string }) {
onKeyDown={(e) => openThread(e, id)}
role="button"
tabIndex={0}
- className="mt-3 cursor-default rounded-lg bg-neutral-200 px-3 py-3 dark:bg-neutral-800"
+ className="mt-3 cursor-default rounded-lg border border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800"
>
- {renderKind(data)}
+ {renderKind(data)}
);
});
diff --git a/src/shared/widgets/local/thread.tsx b/src/shared/widgets/local/thread.tsx
index 63809270..d7e302c3 100644
--- a/src/shared/widgets/local/thread.tsx
+++ b/src/shared/widgets/local/thread.tsx
@@ -57,6 +57,7 @@ export function LocalThreadWidget({ params }: { params: Widget }) {
+
);