Adds dynamic quick actions via modules

Introduces a dynamic quick action system, allowing modules to register actions that appear in a floating action button menu.

This provides a flexible way for modules to extend the application's functionality with common tasks like composing notes or initiating payments.
This commit is contained in:
padreug 2025-11-07 16:00:07 +01:00
parent b286a0315d
commit 678ccff694
4 changed files with 219 additions and 83 deletions

View file

@ -0,0 +1,95 @@
import { computed } from 'vue'
import { pluginManager } from '@/core/plugin-manager'
import type { QuickAction } from '@/core/types'
/**
* Composable for dynamic quick actions based on enabled modules
*
* Quick actions are module-provided action buttons that appear in the floating
* action button (FAB) menu. Each module can register its own quick actions
* for common tasks like composing notes, sending payments, adding expenses, etc.
*
* @example
* ```typescript
* const { quickActions, getActionsByCategory } = useQuickActions()
*
* // Get all actions
* const actions = quickActions.value
*
* // Get actions by category
* const composeActions = getActionsByCategory('compose')
* ```
*/
export function useQuickActions() {
/**
* Get all quick actions from installed modules
* Actions are sorted by order (lower = higher priority)
*/
const quickActions = computed<QuickAction[]>(() => {
const actions: QuickAction[] = []
// Iterate through installed modules
const installedModules = pluginManager.getInstalledModules()
for (const moduleName of installedModules) {
const module = pluginManager.getModule(moduleName)
if (module?.plugin.quickActions) {
actions.push(...module.plugin.quickActions)
}
}
// Sort by order (lower = higher priority), then by label
return actions.sort((a, b) => {
const orderA = a.order ?? 999
const orderB = b.order ?? 999
if (orderA !== orderB) {
return orderA - orderB
}
return a.label.localeCompare(b.label)
})
})
/**
* Get actions filtered by category
*/
const getActionsByCategory = (category: string) => {
return computed(() => {
return quickActions.value.filter(action => action.category === category)
})
}
/**
* Get a specific action by ID
*/
const getActionById = (id: string) => {
return computed(() => {
return quickActions.value.find(action => action.id === id)
})
}
/**
* Check if any actions are available
*/
const hasActions = computed(() => quickActions.value.length > 0)
/**
* Get all unique categories
*/
const categories = computed(() => {
const cats = new Set<string>()
quickActions.value.forEach(action => {
if (action.category) {
cats.add(action.category)
}
})
return Array.from(cats).sort()
})
return {
quickActions,
getActionsByCategory,
getActionById,
hasActions,
categories
}
}

View file

@ -1,6 +1,30 @@
import type { App, Component } from 'vue' import type { App, Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
// Quick action interface for modular action buttons
export interface QuickAction {
/** Unique action ID */
id: string
/** Display label for the action */
label: string
/** Lucide icon name */
icon: string
/** Component to render when action is selected */
component: Component
/** Display order (lower = higher priority) */
order?: number
/** Action category (e.g., 'compose', 'wallet', 'utilities') */
category?: string
/** Whether action requires authentication */
requiresAuth?: boolean
}
// Base module plugin interface // Base module plugin interface
export interface ModulePlugin { export interface ModulePlugin {
/** Unique module name */ /** Unique module name */
@ -32,6 +56,9 @@ export interface ModulePlugin {
/** Composables provided by this module */ /** Composables provided by this module */
composables?: Record<string, any> composables?: Record<string, any>
/** Quick actions provided by this module */
quickActions?: QuickAction[]
} }
// Module configuration for app setup // Module configuration for app setup

View file

@ -1,7 +1,10 @@
import type { App } from 'vue' import type { App } from 'vue'
import { markRaw } from 'vue'
import type { ModulePlugin } from '@/core/types' import type { ModulePlugin } from '@/core/types'
import { container, SERVICE_TOKENS } from '@/core/di-container' import { container, SERVICE_TOKENS } from '@/core/di-container'
import NostrFeed from './components/NostrFeed.vue' import NostrFeed from './components/NostrFeed.vue'
import NoteComposer from './components/NoteComposer.vue'
import RideshareComposer from './components/RideshareComposer.vue'
import { useFeed } from './composables/useFeed' import { useFeed } from './composables/useFeed'
import { FeedService } from './services/FeedService' import { FeedService } from './services/FeedService'
import { ProfileService } from './services/ProfileService' import { ProfileService } from './services/ProfileService'
@ -17,6 +20,28 @@ export const nostrFeedModule: ModulePlugin = {
version: '1.0.0', version: '1.0.0',
dependencies: ['base'], dependencies: ['base'],
// Register quick actions for the FAB menu
quickActions: [
{
id: 'note',
label: 'Note',
icon: 'MessageSquare',
component: markRaw(NoteComposer),
category: 'compose',
order: 1,
requiresAuth: true
},
{
id: 'rideshare',
label: 'Rideshare',
icon: 'Car',
component: markRaw(RideshareComposer),
category: 'compose',
order: 2,
requiresAuth: true
}
],
async install(app: App) { async install(app: App) {
console.log('nostr-feed module: Starting installation...') console.log('nostr-feed module: Starting installation...')

View file

@ -36,24 +36,20 @@
<!-- Main Feed Area - Takes remaining height with scrolling --> <!-- Main Feed Area - Takes remaining height with scrolling -->
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"> <div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<!-- Collapsible Composer --> <!-- Quick Action Component Area -->
<div v-if="showComposer || replyTo" class="border-b bg-background sticky top-0 z-10"> <div v-if="activeAction || replyTo" class="border-b bg-background sticky top-0 z-10">
<div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent"> <div class="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div class="px-4 py-3 sm:px-6"> <div class="px-4 py-3 sm:px-6">
<!-- Regular Note Composer --> <!-- Dynamic Quick Action Component -->
<NoteComposer <component
v-if="composerType === 'note' || replyTo" :is="activeAction?.component"
v-if="activeAction"
:reply-to="replyTo" :reply-to="replyTo"
@note-published="onNotePublished" @note-published="onActionComplete"
@rideshare-published="onActionComplete"
@action-complete="onActionComplete"
@clear-reply="onClearReply" @clear-reply="onClearReply"
@close="onCloseComposer" @close="closeQuickAction"
/>
<!-- Rideshare Composer -->
<RideshareComposer
v-else-if="composerType === 'rideshare'"
@rideshare-published="onRidesharePublished"
@close="onCloseComposer"
/> />
</div> </div>
</div> </div>
@ -72,39 +68,32 @@
</div> </div>
</div> </div>
<!-- Floating Action Buttons for Compose --> <!-- Floating Quick Action Button -->
<div v-if="!showComposer && !replyTo" class="fixed bottom-6 right-6 z-50"> <div v-if="!activeAction && !replyTo && quickActions.length > 0" class="fixed bottom-6 right-6 z-50">
<!-- Main compose button -->
<div class="flex flex-col items-end gap-3"> <div class="flex flex-col items-end gap-3">
<!-- Secondary buttons (when expanded) --> <!-- Quick Action Buttons (when expanded) -->
<div v-if="showComposerOptions" class="flex flex-col gap-2"> <div v-if="showQuickActions" class="flex flex-col gap-2">
<Button <Button
@click="openComposer('note')" v-for="action in quickActions"
:key="action.id"
@click="openQuickAction(action)"
size="lg" size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground" class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
> >
<MessageSquare class="h-4 w-4" /> <component :is="getIconComponent(action.icon)" class="h-4 w-4" />
<span class="text-sm font-medium">Note</span> <span class="text-sm font-medium">{{ action.label }}</span>
</Button>
<Button
@click="openComposer('rideshare')"
size="lg"
class="h-12 px-4 rounded-full shadow-lg hover:shadow-xl transition-all gap-2 bg-card border-2 border-border hover:bg-accent text-card-foreground"
>
<Car class="h-4 w-4" />
<span class="text-sm font-medium">Rideshare</span>
</Button> </Button>
</div> </div>
<!-- Main FAB --> <!-- Main FAB -->
<Button <Button
@click="toggleComposerOptions" @click="toggleQuickActions"
size="lg" size="lg"
class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0" class="h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all bg-primary hover:bg-primary/90 border-2 border-primary-foreground/20 flex items-center justify-center p-0"
> >
<Plus <Plus
class="h-6 w-6 stroke-[2.5] transition-transform duration-200" class="h-6 w-6 stroke-[2.5] transition-transform duration-200"
:class="{ 'rotate-45': showComposerOptions }" :class="{ 'rotate-45': showQuickActions }"
/> />
</Button> </Button>
</div> </div>
@ -136,32 +125,34 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Filter, Plus, MessageSquare, Car } from 'lucide-vue-next' import { Filter, Plus } from 'lucide-vue-next'
import * as LucideIcons from 'lucide-vue-next'
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue' import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue' import FeedFilters from '@/modules/nostr-feed/components/FeedFilters.vue'
import NoteComposer from '@/modules/nostr-feed/components/NoteComposer.vue'
import RideshareComposer from '@/modules/nostr-feed/components/RideshareComposer.vue'
import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue' import NostrFeed from '@/modules/nostr-feed/components/NostrFeed.vue'
import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters' import { FILTER_PRESETS } from '@/modules/nostr-feed/config/content-filters'
import { useQuickActions } from '@/composables/useQuickActions'
import appConfig from '@/app.config' import appConfig from '@/app.config'
import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService' import type { ContentFilter } from '@/modules/nostr-feed/services/FeedService'
import type { ReplyToNote } from '@/modules/nostr-feed/components/NoteComposer.vue' import type { QuickAction } from '@/core/types'
// Get quick actions from modules
const { quickActions } = useQuickActions()
// Get admin pubkeys from app config // Get admin pubkeys from app config
const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || [] const adminPubkeys = appConfig.modules['nostr-feed']?.config?.adminPubkeys || []
// UI state // UI state
const showFilters = ref(false) const showFilters = ref(false)
const showComposer = ref(false) const showQuickActions = ref(false)
const showComposerOptions = ref(false) const activeAction = ref<QuickAction | null>(null)
const composerType = ref<'note' | 'rideshare'>('note')
// Feed configuration // Feed configuration
const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all) const selectedFilters = ref<ContentFilter[]>(FILTER_PRESETS.all)
const feedKey = ref(0) // Force feed component to re-render when filters change const feedKey = ref(0) // Force feed component to re-render when filters change
// Note composer state // Reply state (for note composer compatibility)
const replyTo = ref<ReplyToNote | undefined>() const replyTo = ref<any | undefined>()
// Quick filter presets for mobile bottom bar // Quick filter presets for mobile bottom bar
const quickFilterPresets = { const quickFilterPresets = {
@ -221,48 +212,46 @@ const setQuickFilter = (presetKey: string) => {
} }
} }
const onNotePublished = (noteId: string) => { // Quick action methods
console.log('Note published:', noteId) const toggleQuickActions = () => {
// Refresh the feed to show the new note showQuickActions.value = !showQuickActions.value
feedKey.value++ }
// Clear reply state and hide composer
const openQuickAction = (action: QuickAction) => {
activeAction.value = action
showQuickActions.value = false
}
const closeQuickAction = () => {
activeAction.value = null
replyTo.value = undefined
}
// Event handlers for quick action components
const onActionComplete = (eventData?: any) => {
console.log('Quick action completed:', activeAction.value?.id, eventData)
// Refresh the feed to show new content
feedKey.value++
// Close the action
activeAction.value = null
replyTo.value = undefined replyTo.value = undefined
showComposer.value = false
} }
const onClearReply = () => { const onClearReply = () => {
replyTo.value = undefined replyTo.value = undefined
showComposer.value = false
} }
const onReplyToNote = (note: ReplyToNote) => { const onReplyToNote = (note: any) => {
replyTo.value = note replyTo.value = note
showComposer.value = true // Find and open the note composer action
const noteAction = quickActions.value.find(a => a.id === 'note')
if (noteAction) {
activeAction.value = noteAction
}
} }
const onCloseComposer = () => { // Helper to get Lucide icon component
showComposer.value = false const getIconComponent = (iconName: string) => {
showComposerOptions.value = false return (LucideIcons as any)[iconName] || Plus
replyTo.value = undefined
}
// New composer methods
const toggleComposerOptions = () => {
showComposerOptions.value = !showComposerOptions.value
}
const openComposer = (type: 'note' | 'rideshare') => {
composerType.value = type
showComposer.value = true
showComposerOptions.value = false
}
const onRidesharePublished = (noteId: string) => {
console.log('Rideshare post published:', noteId)
// Refresh the feed to show the new rideshare post
feedKey.value++
// Hide composer
showComposer.value = false
showComposerOptions.value = false
} }
</script> </script>