Enhance content management and user interaction features
- Introduced a new content source toggle in the profile and app header to switch between IndeeHub and TopDoc films. - Updated the content fetching logic to dynamically load content based on the selected source. - Enhanced the seeding process to include a combined catalog of IndeeHub and TopDoc films, ensuring diverse content availability. - Improved user interaction by preventing duplicate reactions and ensuring a smoother voting experience across comments and content. - Added support for Amber login (NIP-55) for Android users, integrating it into the existing authentication flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import { accountManager } from '../lib/accounts'
|
||||
import { accountManager, AmberClipboardSigner, AmberClipboardAccount } from '../lib/accounts'
|
||||
import { TEST_PERSONAS, TASTEMAKER_PERSONAS } from '../data/testPersonas'
|
||||
import type { Subscription } from 'rxjs'
|
||||
|
||||
@@ -101,6 +101,35 @@ export function useAccounts() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Amber signer is supported on this platform (Android + clipboard)
|
||||
*/
|
||||
const isAmberSupported = computed(() => !!AmberClipboardSigner.SUPPORTED)
|
||||
|
||||
/**
|
||||
* Login with Amber (NIP-55 Android Signer).
|
||||
* Uses the AmberClipboardSigner to request the pubkey via Android intents.
|
||||
* The signer is retained for future event signing (comments, reactions, etc.)
|
||||
*/
|
||||
async function loginWithAmber() {
|
||||
isLoggingIn.value = true
|
||||
loginError.value = null
|
||||
try {
|
||||
const signer = new AmberClipboardSigner()
|
||||
const pubkey = await signer.getPublicKey()
|
||||
const account = new AmberClipboardAccount(pubkey, signer)
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
return pubkey
|
||||
} catch (err: any) {
|
||||
loginError.value = err.message || 'Amber login failed'
|
||||
console.error('Amber login error:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoggingIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout current account
|
||||
*/
|
||||
@@ -142,8 +171,12 @@ export function useAccounts() {
|
||||
loginWithExtension,
|
||||
loginWithPersona,
|
||||
loginWithPrivateKey,
|
||||
loginWithAmber,
|
||||
logout,
|
||||
|
||||
// Platform checks
|
||||
isAmberSupported,
|
||||
|
||||
// Personas for dev UI
|
||||
testPersonas,
|
||||
tastemakerPersonas,
|
||||
|
||||
@@ -320,6 +320,7 @@ export function useNostr(contentId?: string) {
|
||||
/**
|
||||
* Post a reaction (+/-) on the movie/content itself.
|
||||
* Uses kind 17 with #i and #k tags.
|
||||
* One vote per user per content — rejects duplicates.
|
||||
*/
|
||||
async function postReaction(positive: boolean, id: string = contentId!) {
|
||||
if (!id) throw new Error('Content ID required')
|
||||
@@ -327,6 +328,14 @@ export function useNostr(contentId?: string) {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('Not logged in')
|
||||
|
||||
// Prevent duplicate votes (check store, not just UI state)
|
||||
const existingVote = reactions.value.find(
|
||||
(r) => r.pubkey === account.pubkey,
|
||||
)
|
||||
if (existingVote) {
|
||||
throw new Error('You have already voted on this content')
|
||||
}
|
||||
|
||||
const externalId = getExternalContentId(id)
|
||||
|
||||
try {
|
||||
@@ -353,11 +362,18 @@ export function useNostr(contentId?: string) {
|
||||
/**
|
||||
* React to a comment event (+/-).
|
||||
* Uses factory.reaction(event, emoji) which creates kind 7 events.
|
||||
* One reaction per user per comment — rejects duplicates.
|
||||
*/
|
||||
async function reactToComment(event: NostrEvent, positive: boolean) {
|
||||
const account = accountManager.active
|
||||
if (!account) throw new Error('Not logged in')
|
||||
|
||||
// Prevent duplicate comment reactions
|
||||
const existing = commentReactions.value.get(event.id) || []
|
||||
if (existing.some((r) => r.pubkey === account.pubkey)) {
|
||||
throw new Error('You have already reacted to this comment')
|
||||
}
|
||||
|
||||
const currentRelays = relays.value ?? APP_RELAYS
|
||||
|
||||
try {
|
||||
@@ -375,21 +391,39 @@ export function useNostr(contentId?: string) {
|
||||
// --- Computed State ---
|
||||
|
||||
/**
|
||||
* Movie-level reaction counts.
|
||||
* Deduplicated reactions: keep only the latest reaction per pubkey.
|
||||
* Prevents inflated counts when a user has published multiple events.
|
||||
*/
|
||||
const uniqueReactions = computed(() => {
|
||||
const byPubkey = new Map<string, NostrEvent>()
|
||||
for (const r of reactions.value) {
|
||||
const existing = byPubkey.get(r.pubkey)
|
||||
if (!existing || r.created_at > existing.created_at) {
|
||||
byPubkey.set(r.pubkey, r)
|
||||
}
|
||||
}
|
||||
return [...byPubkey.values()]
|
||||
})
|
||||
|
||||
/**
|
||||
* Movie-level reaction counts (deduplicated per user).
|
||||
*/
|
||||
const reactionCounts = computed(() => {
|
||||
const positive = reactions.value.filter((r) => r.content === '+').length
|
||||
const negative = reactions.value.filter((r) => r.content === '-').length
|
||||
const positive = uniqueReactions.value.filter((r) => r.content === '+').length
|
||||
const negative = uniqueReactions.value.filter((r) => r.content === '-').length
|
||||
return { positive, negative, total: positive - negative }
|
||||
})
|
||||
|
||||
/**
|
||||
* Current user's movie-level reaction, read from relay data.
|
||||
* Uses the latest event if multiple exist.
|
||||
*/
|
||||
const userContentReaction = computed((): string | null => {
|
||||
const account = accountManager.active
|
||||
if (!account) return null
|
||||
const userReaction = reactions.value.find((r) => r.pubkey === account.pubkey)
|
||||
const userReaction = uniqueReactions.value.find(
|
||||
(r) => r.pubkey === account.pubkey,
|
||||
)
|
||||
return userReaction?.content || null
|
||||
})
|
||||
|
||||
@@ -399,12 +433,27 @@ export function useNostr(contentId?: string) {
|
||||
const hasVotedOnContent = computed(() => userContentReaction.value !== null)
|
||||
|
||||
/**
|
||||
* Get reaction counts for a specific comment event.
|
||||
* Deduplicate comment reactions: one per pubkey, keep latest.
|
||||
*/
|
||||
function getUniqueCommentReactions(eventId: string): NostrEvent[] {
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const byPubkey = new Map<string, NostrEvent>()
|
||||
for (const r of events) {
|
||||
const existing = byPubkey.get(r.pubkey)
|
||||
if (!existing || r.created_at > existing.created_at) {
|
||||
byPubkey.set(r.pubkey, r)
|
||||
}
|
||||
}
|
||||
return [...byPubkey.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reaction counts for a specific comment event (deduplicated per user).
|
||||
*/
|
||||
function getCommentReactionCounts(eventId: string) {
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const positive = events.filter((r) => r.content === '+').length
|
||||
const negative = events.filter((r) => r.content === '-').length
|
||||
const unique = getUniqueCommentReactions(eventId)
|
||||
const positive = unique.filter((r) => r.content === '+').length
|
||||
const negative = unique.filter((r) => r.content === '-').length
|
||||
return { positive, negative }
|
||||
}
|
||||
|
||||
@@ -414,8 +463,8 @@ export function useNostr(contentId?: string) {
|
||||
function getUserCommentReaction(eventId: string): string | null {
|
||||
const account = accountManager.active
|
||||
if (!account) return null
|
||||
const events = commentReactions.value.get(eventId) || []
|
||||
const userReaction = events.find((r) => r.pubkey === account.pubkey)
|
||||
const unique = getUniqueCommentReactions(eventId)
|
||||
const userReaction = unique.find((r) => r.pubkey === account.pubkey)
|
||||
return userReaction?.content || null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user