diff --git a/android/app/src/main/java/com/nostros/classes/Event.java b/android/app/src/main/java/com/nostros/classes/Event.java index e981ee0..4079003 100644 --- a/android/app/src/main/java/com/nostros/classes/Event.java +++ b/android/app/src/main/java/com/nostros/classes/Event.java @@ -47,6 +47,8 @@ public class Event { } } else if (kind.equals("4")) { saveDirectMessage(database); + } else if (kind.equals("7")) { + saveReaction(database); } } catch (JSONException e) { e.printStackTrace(); @@ -99,11 +101,11 @@ public class Event { } protected Boolean getUserMentioned(String userPubKey) { - JSONArray eTags = filterTags("p"); + JSONArray pTags = filterTags("p"); Boolean userMentioned = false; try { - for (int i = 0; i < eTags.length(); ++i) { - JSONArray tag = eTags.getJSONArray(i); + for (int i = 0; i < pTags.length(); ++i) { + JSONArray tag = pTags.getJSONArray(i); if (tag.getString(1).equals(userPubKey)) { userMentioned = true; } @@ -115,26 +117,6 @@ public class Event { return userMentioned; } - protected String saveFollower(String pubKey) { - JSONArray eTags = filterTags("p"); - String mainEventId = null; - try { - for (int i = 0; i < eTags.length(); ++i) { - JSONArray tag = eTags.getJSONArray(i); - if (tag.getString(3).equals("reply")) { - mainEventId = tag.getString(1); - } - } - if (mainEventId == null && eTags.length() > 0) { - mainEventId = eTags.getJSONArray(eTags.length() - 1).getString(1); - } - } catch (JSONException e) { - e.printStackTrace(); - } - - return mainEventId; - } - protected JSONArray filterTags(String kind) { JSONArray filtered = new JSONArray(); @@ -190,13 +172,29 @@ public class Event { @SuppressLint("Recycle") Cursor cursor = database.rawQuery(query, new String[] {id}); if (cursor.getCount() == 0) { database.insert("nostros_direct_messages", null, values); - } else { - String whereClause = "id = ?"; - String[] whereArgs = new String[] { - id - }; - values.put("read", cursor.getInt(0)); - database.update("nostros_direct_messages", values, whereClause, whereArgs); + } + } + + protected void saveReaction(SQLiteDatabase database) throws JSONException { + JSONArray pTags = filterTags("p"); + JSONArray eTags = filterTags("e"); + + ContentValues values = new ContentValues(); + values.put("id", id); + values.put("content", content.replace("'", "''")); + values.put("created_at", created_at); + values.put("kind", kind); + values.put("pubkey", pubkey); + values.put("sig", sig); + values.put("tags", tags.toString()); + values.put("positive", !content.equals("-")); + values.put("reacted_event_id", eTags.getJSONArray(eTags.length() - 1).getString(1)); + values.put("reacted_user_id", pTags.getJSONArray(pTags.length() - 1).getString(1)); + + String query = "SELECT id FROM nostros_reactions WHERE id = ?"; + @SuppressLint("Recycle") Cursor cursor = database.rawQuery(query, new String[] {id}); + if (cursor.getCount() == 0) { + database.insert("nostros_reactions", null, values); } } diff --git a/android/app/src/main/java/com/nostros/modules/DatabaseModule.java b/android/app/src/main/java/com/nostros/modules/DatabaseModule.java index 31757d8..ef5e678 100644 --- a/android/app/src/main/java/com/nostros/modules/DatabaseModule.java +++ b/android/app/src/main/java/com/nostros/modules/DatabaseModule.java @@ -68,6 +68,18 @@ public class DatabaseModule { try { database.execSQL("ALTER TABLE nostros_users ADD COLUMN created_at INT DEFAULT 0;"); } catch (SQLException e) { } + database.execSQL("CREATE TABLE IF NOT EXISTS nostros_reactions(\n" + + " id TEXT PRIMARY KEY NOT NULL, \n" + + " content TEXT NOT NULL,\n" + + " created_at INT NOT NULL,\n" + + " kind INT NOT NULL,\n" + + " pubkey TEXT NOT NULL,\n" + + " sig TEXT NOT NULL,\n" + + " tags TEXT NOT NULL,\n" + + " positive BOOLEAN DEFAULT TRUE,\n" + + " reacted_event_id TEXT,\n" + + " reacted_user_id TEXT\n" + + " );"); } public void saveEvent(JSONObject data, String userPubKey) throws JSONException { diff --git a/frontend/Components/NoteCard/index.tsx b/frontend/Components/NoteCard/index.tsx index 81e55a8..3b22fba 100644 --- a/frontend/Components/NoteCard/index.tsx +++ b/frontend/Components/NoteCard/index.tsx @@ -13,6 +13,7 @@ import { getReplyEventId, isContentWarning, } from '../../Functions/RelayFunctions/Events' +import { Event } from '../../../lib/nostr/Events' import moment from 'moment' import { populateRelay } from '../../Functions/RelayFunctions' import Avatar from '../Avatar' @@ -20,6 +21,7 @@ import { searchRelays } from '../../Functions/DatabaseFunctions/Relays' import { RelayFilters } from '../../lib/nostr/RelayPool/intex' import TextContent from '../../Components/TextContent' import { formatPubKey } from '../../Functions/RelayFunctions/Users' +import { getReactionsCount, getUserReaction } from '../../Functions/DatabaseFunctions/Reactions' interface NoteCardProps { note: Note @@ -33,12 +35,36 @@ export const NoteCard: React.FC = ({ onlyContactsReplies = false, }) => { const theme = useTheme() - const { relayPool, publicKey } = useContext(RelayPoolContext) + const { relayPool, publicKey, lastEventId } = useContext(RelayPoolContext) const { database, goToPage } = useContext(AppContext) const [relayAdded, setRelayAdded] = useState(false) const [replies, setReplies] = useState([]) + const [positiveReactions, setPositiveReactions] = useState(0) + const [negaiveReactions, setNegativeReactions] = useState(0) + const [userUpvoted, setUserUpvoted] = useState(false) + const [userDownvoted, setUserDownvoted] = useState(false) const [hide, setHide] = useState(isContentWarning(note)) + useEffect(() => { + if (database && publicKey && note.id) { + getReactionsCount(database, { positive: true, eventId: note.id }).then((result) => { + setPositiveReactions(result ?? 0) + }) + getReactionsCount(database, { positive: false, eventId: note.id }).then((result) => { + setNegativeReactions(result ?? 0) + }) + getUserReaction(database, publicKey, { eventId: note.id }).then((results) => { + results.forEach((reaction) => { + if (reaction.positive) { + setUserUpvoted(true) + } else { + setUserDownvoted(true) + } + }) + }) + } + }, [lastEventId]) + useEffect(() => { if (database && note) { searchRelays(note.content, database).then((result) => { @@ -63,6 +89,17 @@ export const NoteCard: React.FC = ({ } }, [database]) + const publishReaction: (positive: boolean) => void = (positive) => { + const event: Event = { + content: positive ? '+' : '-', + created_at: moment().unix(), + kind: EventKind.reaction, + pubkey: publicKey, + tags: [...note.tags, ['e', note.id], ['p', note.pubkey]], + } + relayPool?.sendEvent(event) + } + const textNote: (note: Note) => JSX.Element = (note) => { return ( <> @@ -99,6 +136,54 @@ export const NoteCard: React.FC = ({ {moment.unix(note.created_at).format('HH:mm DD-MM-YY')} + + + + @@ -192,6 +277,14 @@ export const NoteCard: React.FC = ({ }, footer: { backgroundColor: 'transparent', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 10, + }, + reactions: { + backgroundColor: 'transparent', + flexDirection: 'row', }, tags: { backgroundColor: 'transparent', diff --git a/frontend/Components/TextContent/index.tsx b/frontend/Components/TextContent/index.tsx index 1201f8a..5055887 100644 --- a/frontend/Components/TextContent/index.tsx +++ b/frontend/Components/TextContent/index.tsx @@ -112,7 +112,12 @@ export const TextContent: React.FC = ({ event, content, previe {text} {preview && containsUrl() && ( - ''} textContainerStyle={{ height: 0 }} /> + ''} + textContainerStyle={{ height: 0 }} + enableAnimation={true} + /> )} ) diff --git a/frontend/Functions/DatabaseFunctions/Reactions/index.ts b/frontend/Functions/DatabaseFunctions/Reactions/index.ts new file mode 100644 index 0000000..118a0ab --- /dev/null +++ b/frontend/Functions/DatabaseFunctions/Reactions/index.ts @@ -0,0 +1,67 @@ +import { QuickSQLiteConnection } from 'react-native-quick-sqlite' +import { getItems } from '..' +import { Event } from '../../../lib/nostr/Events' + +export interface Reaction extends Event { + positive: boolean + reacted_event_id: string + reacted_user_id: string +} + +const databaseToEntity: (object: object) => Reaction = (object) => { + return object as Reaction +} + +export const getReactionsCount: ( + db: QuickSQLiteConnection, + filters: { + eventId?: string + pubKey?: string + positive: boolean + }, +) => Promise = async (db, { eventId, pubKey, positive }) => { + let notesQuery = ` + SELECT + COUNT(*) + FROM + nostros_reactions + WHERE positive = ${positive ? '1' : '0'} + ` + + if (eventId) { + notesQuery += `AND reacted_event_id = "${eventId}" ` + } else if (pubKey) { + notesQuery += `AND reacted_user_id = "${pubKey}" ` + } + + const resultSet = await db.execute(notesQuery) + const item: { 'COUNT(*)': number } = resultSet?.rows?.item(0) + + return item['COUNT(*)'] +} + +export const getUserReaction: ( + db: QuickSQLiteConnection, + pubKey: string, + filters: { + eventId?: string + }, +) => Promise = async (db, pubKey, { eventId }) => { + let notesQuery = ` + SELECT + * + FROM + nostros_reactions + WHERE pubkey = '${pubKey}' + ` + + if (eventId) { + notesQuery += `AND reacted_event_id = "${eventId}" ` + } + + const resultSet = await db.execute(notesQuery) + const items: object[] = getItems(resultSet) + const reactions: Reaction[] = items.map((object) => databaseToEntity(object)) + + return reactions +} diff --git a/frontend/Functions/RelayFunctions/Events/index.ts b/frontend/Functions/RelayFunctions/Events/index.ts index 74c971f..fe3edc2 100644 --- a/frontend/Functions/RelayFunctions/Events/index.ts +++ b/frontend/Functions/RelayFunctions/Events/index.ts @@ -22,6 +22,8 @@ export const isContentWarning: (event: Event) => boolean = (event) => { } export const isDirectReply: (mainEvent: Event, reply: Event) => boolean = (mainEvent, reply) => { + if (mainEvent.id === reply.id) return false + const taggedMainEventsIds: string[] = getTaggedEventIds(mainEvent) const taggedReplyEventsIds: string[] = getTaggedEventIds(reply) const difference = taggedReplyEventsIds.filter((item) => !taggedMainEventsIds.includes(item)) diff --git a/frontend/Pages/HomePage/index.tsx b/frontend/Pages/HomePage/index.tsx index 97acda1..ff3eae4 100644 --- a/frontend/Pages/HomePage/index.tsx +++ b/frontend/Pages/HomePage/index.tsx @@ -49,7 +49,7 @@ export const HomePage: React.FC = () => { authors: [...users.map((user) => user.id), publicKey], } - if (lastNote && lastNotes.length > 0 && !past) { + if (lastNote && lastNotes.length >= pageSize && !past) { message.since = lastNote.created_at } else { message.limit = pageSize + initialPageSize @@ -67,6 +67,10 @@ export const HomePage: React.FC = () => { kinds: [EventKind.meta], authors: notes.map((note) => note.pubkey), }) + relayPool?.subscribe('main-channel', { + kinds: [EventKind.reaction], + '#e': notes.map((note) => note.id ?? ''), + }) }) } } @@ -158,9 +162,11 @@ export const HomePage: React.FC = () => { refreshControl={} > {notes.map((note) => itemCard(note))} - - - + {notes.length >= 10 && ( + + + + )} ) : ( diff --git a/frontend/Pages/MentionsPage/index.tsx b/frontend/Pages/MentionsPage/index.tsx index da34c90..6b597dd 100644 --- a/frontend/Pages/MentionsPage/index.tsx +++ b/frontend/Pages/MentionsPage/index.tsx @@ -42,6 +42,10 @@ export const MentionsPage: React.FC = () => { if (database && publicKey) { getMentionNotes(database, publicKey, pageSize).then((notes) => { setNotes(notes) + relayPool?.subscribe('main-channel', { + kinds: [EventKind.reaction], + '#e': notes.map((note) => note.id ?? ''), + }) const missingDataNotes = notes .filter((note) => !note.picture || note.picture === '') .map((note) => note.pubkey) @@ -124,9 +128,11 @@ export const MentionsPage: React.FC = () => { {notes && notes.length > 0 ? ( {notes.map((note) => itemCard(note))} - - - + {notes.length >= 10 && ( + + + + )} ) : ( diff --git a/frontend/Pages/NotePage/index.tsx b/frontend/Pages/NotePage/index.tsx index e4f8801..cc82ae0 100644 --- a/frontend/Pages/NotePage/index.tsx +++ b/frontend/Pages/NotePage/index.tsx @@ -76,17 +76,10 @@ export const NotePage: React.FC = () => { kinds: [EventKind.textNote], ids: [eventId], }) - const notes = await getNotes(database, { filters: { reply_event_id: eventId } }) - const eventMessages: RelayFilters = { - kinds: [EventKind.textNote], + relayPool?.subscribe('main-channel', { + kinds: [EventKind.reaction, EventKind.textNote], '#e': [eventId], - } - if (past) { - eventMessages.until = notes[notes.length - 1]?.created_at - } else { - eventMessages.since = notes[0]?.created_at - } - relayPool?.subscribe('main-channel', eventMessages) + }) } } diff --git a/frontend/Pages/ProfilePage/index.tsx b/frontend/Pages/ProfilePage/index.tsx index 93b5de3..80b7448 100644 --- a/frontend/Pages/ProfilePage/index.tsx +++ b/frontend/Pages/ProfilePage/index.tsx @@ -67,6 +67,10 @@ export const ProfilePage: React.FC = () => { getNotes(database, { filters: { pubkey: userId }, limit: pageSize }).then((results) => { setNotes(results) setRefreshing(false) + relayPool?.subscribe('main-channel', { + kinds: [EventKind.reaction], + '#e': results.map((note) => note.id ?? ''), + }) }) } } @@ -79,7 +83,6 @@ export const ProfilePage: React.FC = () => { authors: [userId], limit: pageSize, } - relayPool?.subscribe('main-channel', message) } @@ -331,9 +334,11 @@ export const ProfilePage: React.FC = () => { refreshControl={} > {notes.map((note) => itemCard(note))} - - - + {notes.length >= 10 && ( + + + + )} ) : ( diff --git a/frontend/lib/nostr/Events/index.ts b/frontend/lib/nostr/Events/index.ts index 22459f7..9b1eabe 100644 --- a/frontend/lib/nostr/Events/index.ts +++ b/frontend/lib/nostr/Events/index.ts @@ -16,6 +16,7 @@ export enum EventKind { recommendServer = 2, petNames = 3, directMessage = 4, + reaction = 7, } export const serializeEvent: (event: Event) => string = (event) => {