Squash merge feat/expo-app: app-expo, .cursor, workflows, package.json, .husky; remove app-android, app-ios, react-app
This commit is contained in:
131
app-expo/src/features/voice/hooks/use-player.ts
Normal file
131
app-expo/src/features/voice/hooks/use-player.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
|
||||
import type { PlaybackItem, PlayerStatus } from '../types';
|
||||
|
||||
interface UsePlayerResult {
|
||||
status: PlayerStatus;
|
||||
queueLength: number;
|
||||
enqueueTtsAudio: (audioBase64: string) => void;
|
||||
enqueue: (item: PlaybackItem) => void;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained audio playback hook with queue management.
|
||||
*
|
||||
* - Uses useAudioPlayer + useAudioPlayerStatus for native playback
|
||||
* - Detects playback completion to auto-advance the queue
|
||||
* - Coordinates with audioFocus for recording/playback mutual exclusion
|
||||
* - Subscribes to audioFocus owner changes to resume after recording ends
|
||||
*/
|
||||
export function usePlayer(): UsePlayerResult {
|
||||
const queueRef = useRef<PlaybackItem[]>([]);
|
||||
const [status, setStatus] = useState<PlayerStatus>('idle');
|
||||
const [queueLength, setQueueLength] = useState(0);
|
||||
const [currentSource, setCurrentSource] = useState<string | null>(null);
|
||||
const isPlayingRef = useRef(false);
|
||||
const wasBlockedByRecorderRef = useRef(false);
|
||||
|
||||
const player = useAudioPlayer(currentSource);
|
||||
const playerStatus = useAudioPlayerStatus(player);
|
||||
|
||||
// Start playback when a new source is set
|
||||
useEffect(() => {
|
||||
if (currentSource && player) {
|
||||
player.play();
|
||||
isPlayingRef.current = true;
|
||||
}
|
||||
}, [currentSource, player]);
|
||||
|
||||
const playNext = useCallback(async () => {
|
||||
if (queueRef.current.length === 0) {
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
setQueueLength(0);
|
||||
await audioFocus.release();
|
||||
return;
|
||||
}
|
||||
|
||||
const acquired = await audioFocus.acquireForPlayback();
|
||||
if (!acquired) {
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
const next = queueRef.current.shift()!;
|
||||
setQueueLength(queueRef.current.length);
|
||||
setStatus('playing');
|
||||
setCurrentSource(next.uri);
|
||||
}, []);
|
||||
|
||||
// Detect playback completion → advance queue
|
||||
useEffect(() => {
|
||||
if (!currentSource || !isPlayingRef.current) return;
|
||||
|
||||
const { playing, currentTime, duration } = playerStatus;
|
||||
const finished = !playing && duration > 0 && currentTime >= duration - 0.05;
|
||||
|
||||
if (finished) {
|
||||
isPlayingRef.current = false;
|
||||
playNext();
|
||||
}
|
||||
}, [playerStatus, currentSource, playNext]);
|
||||
|
||||
// Subscribe to audioFocus owner changes for recorder → idle recovery
|
||||
useEffect(() => {
|
||||
const unsub = audioFocus.onOwnerChange((owner) => {
|
||||
if (owner === 'recorder') {
|
||||
wasBlockedByRecorderRef.current =
|
||||
queueRef.current.length > 0 || currentSource !== null;
|
||||
}
|
||||
|
||||
if (owner === null && wasBlockedByRecorderRef.current) {
|
||||
wasBlockedByRecorderRef.current = false;
|
||||
if (queueRef.current.length > 0 && status === 'idle') {
|
||||
playNext();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [status, currentSource, playNext]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
async (item: PlaybackItem) => {
|
||||
queueRef.current.push(item);
|
||||
setQueueLength(queueRef.current.length);
|
||||
|
||||
if (status === 'idle' && !currentSource) {
|
||||
await playNext();
|
||||
}
|
||||
},
|
||||
[status, currentSource, playNext],
|
||||
);
|
||||
|
||||
const enqueueTtsAudio = useCallback(
|
||||
(audioBase64: string) => {
|
||||
const uri = `data:audio/mp3;base64,${audioBase64}`;
|
||||
enqueue({ uri, label: 'TTS' });
|
||||
},
|
||||
[enqueue],
|
||||
);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
queueRef.current = [];
|
||||
setQueueLength(0);
|
||||
isPlayingRef.current = false;
|
||||
|
||||
if (player) {
|
||||
player.pause();
|
||||
}
|
||||
|
||||
setCurrentSource(null);
|
||||
setStatus('idle');
|
||||
await audioFocus.release();
|
||||
}, [player]);
|
||||
|
||||
return { status, queueLength, enqueueTtsAudio, enqueue, stop };
|
||||
}
|
||||
82
app-expo/src/features/voice/hooks/use-recorder.ts
Normal file
82
app-expo/src/features/voice/hooks/use-recorder.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { VoiceRecorder } from '../recorder';
|
||||
import type { RecorderStatus } from '../types';
|
||||
|
||||
interface UseRecorderResult {
|
||||
status: RecorderStatus;
|
||||
durationMs: number;
|
||||
start: () => Promise<boolean>;
|
||||
stop: () => Promise<{ uri: string; durationMs: number } | null>;
|
||||
cancel: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useRecorder(
|
||||
onRecordingComplete?: (uri: string, durationMs: number) => void,
|
||||
): UseRecorderResult {
|
||||
const recorderRef = useRef<VoiceRecorder | null>(null);
|
||||
const onCompleteRef = useRef(onRecordingComplete);
|
||||
onCompleteRef.current = onRecordingComplete;
|
||||
|
||||
const [status, setStatus] = useState<RecorderStatus>('idle');
|
||||
const [durationMs, setDurationMs] = useState(0);
|
||||
const durationTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const stableOnComplete = useCallback((uri: string, durationMs: number) => {
|
||||
onCompleteRef.current?.(uri, durationMs);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const recorder = new VoiceRecorder();
|
||||
recorderRef.current = recorder;
|
||||
|
||||
const unsubStatus = recorder.onStatusChange(setStatus);
|
||||
const unsubComplete = recorder.onRecordingComplete(stableOnComplete);
|
||||
|
||||
return () => {
|
||||
unsubStatus();
|
||||
unsubComplete();
|
||||
recorder.cancel();
|
||||
recorderRef.current = null;
|
||||
if (durationTimer.current) clearInterval(durationTimer.current);
|
||||
};
|
||||
}, [stableOnComplete]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) return false;
|
||||
|
||||
const ok = await recorder.start();
|
||||
if (ok) {
|
||||
setDurationMs(0);
|
||||
durationTimer.current = setInterval(() => {
|
||||
setDurationMs(recorder.getDurationMs());
|
||||
}, 200);
|
||||
}
|
||||
return ok;
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (durationTimer.current) {
|
||||
clearInterval(durationTimer.current);
|
||||
durationTimer.current = null;
|
||||
}
|
||||
setDurationMs(0);
|
||||
const recorder = recorderRef.current;
|
||||
if (!recorder) return null;
|
||||
setStatus('stopping');
|
||||
const result = (await recorder.stop()) ?? null;
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
if (durationTimer.current) {
|
||||
clearInterval(durationTimer.current);
|
||||
durationTimer.current = null;
|
||||
}
|
||||
await recorderRef.current?.cancel();
|
||||
setDurationMs(0);
|
||||
}, []);
|
||||
|
||||
return { status, durationMs, start, stop, cancel };
|
||||
}
|
||||
9
app-expo/src/features/voice/player.ts
Normal file
9
app-expo/src/features/voice/player.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Player is now fully implemented in hooks/use-player.ts as a self-contained
|
||||
* React hook. expo-audio's hook-centric API (useAudioPlayer + useAudioPlayerStatus)
|
||||
* makes a class-level player impractical — completion detection, source replacement,
|
||||
* and lifecycle management all need React context.
|
||||
*
|
||||
* This file re-exports the hook for backward compatibility with any imports.
|
||||
*/
|
||||
export { usePlayer } from './hooks/use-player';
|
||||
156
app-expo/src/features/voice/recorder.ts
Normal file
156
app-expo/src/features/voice/recorder.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import {
|
||||
AudioModule,
|
||||
type RecordingOptions,
|
||||
RecordingPresets,
|
||||
} from 'expo-audio';
|
||||
import { audioFocus } from '@/core/audio/audio-focus';
|
||||
|
||||
import type { RecorderStatus } from './types';
|
||||
|
||||
// Native module exposes AudioRecorder as constructor; ESLint import/namespace doesn't resolve it
|
||||
// eslint-disable-next-line import/namespace -- AudioModule.AudioRecorder exists at runtime
|
||||
const AudioRecorderCtor = AudioModule.AudioRecorder;
|
||||
|
||||
type StatusListener = (status: RecorderStatus) => void;
|
||||
type RecordingCompleteListener = (uri: string, durationMs: number) => void;
|
||||
|
||||
/** Platform-specific recording options (expo-audio internal createRecordingOptions logic). */
|
||||
function createRecordingOptions(
|
||||
options: RecordingOptions,
|
||||
): Partial<RecordingOptions> {
|
||||
const common = {
|
||||
extension: options.extension,
|
||||
sampleRate: options.sampleRate,
|
||||
numberOfChannels: options.numberOfChannels,
|
||||
bitRate: options.bitRate,
|
||||
isMeteringEnabled: options.isMeteringEnabled ?? false,
|
||||
};
|
||||
if (Platform.OS === 'ios') return { ...common, ...options.ios };
|
||||
if (Platform.OS === 'android') return { ...common, ...options.android };
|
||||
return { ...common, ...options.web };
|
||||
}
|
||||
|
||||
/**
|
||||
* Class-level wrapper over expo-audio recording.
|
||||
* No React dependency — hooks/ layer adapts this to React.
|
||||
*/
|
||||
interface RecorderInstance {
|
||||
prepareToRecordAsync(options?: Partial<RecordingOptions>): Promise<void>;
|
||||
record(): void;
|
||||
stop(): Promise<void>;
|
||||
uri: string | null;
|
||||
}
|
||||
|
||||
export class VoiceRecorder {
|
||||
private recorder: RecorderInstance | null = null;
|
||||
private status: RecorderStatus = 'idle';
|
||||
private startTime = 0;
|
||||
private statusListeners = new Set<StatusListener>();
|
||||
private completeListeners = new Set<RecordingCompleteListener>();
|
||||
|
||||
async requestPermission(): Promise<boolean> {
|
||||
const result = await AudioModule.requestRecordingPermissionsAsync();
|
||||
return result.granted;
|
||||
}
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
if (this.status !== 'idle') return false;
|
||||
|
||||
const hasPermission = await this.requestPermission();
|
||||
if (!hasPermission) return false;
|
||||
|
||||
const acquired = await audioFocus.acquireForRecording();
|
||||
if (!acquired) return false;
|
||||
|
||||
this.setStatus('preparing');
|
||||
|
||||
try {
|
||||
this.recorder = new AudioRecorderCtor(
|
||||
createRecordingOptions(RecordingPresets.HIGH_QUALITY),
|
||||
);
|
||||
await this.recorder.prepareToRecordAsync();
|
||||
this.recorder.record();
|
||||
this.startTime = Date.now();
|
||||
this.setStatus('recording');
|
||||
return true;
|
||||
} catch {
|
||||
await this.cleanup();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<{ uri: string; durationMs: number } | null> {
|
||||
if (!this.recorder || this.status !== 'recording') {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.setStatus('stopping');
|
||||
|
||||
try {
|
||||
await this.recorder.stop();
|
||||
const uri = this.recorder.uri;
|
||||
const durationMs = Date.now() - this.startTime;
|
||||
|
||||
if (uri) {
|
||||
for (const listener of this.completeListeners) {
|
||||
listener(uri, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
await this.cleanup();
|
||||
return uri ? { uri, durationMs } : null;
|
||||
} catch {
|
||||
await this.cleanup();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
if (this.recorder) {
|
||||
try {
|
||||
await this.recorder.stop();
|
||||
} catch {
|
||||
// Already stopped or failed
|
||||
}
|
||||
}
|
||||
await this.cleanup();
|
||||
}
|
||||
|
||||
getStatus(): RecorderStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getDurationMs(): number {
|
||||
if (this.status === 'recording' || this.status === 'paused') {
|
||||
return Date.now() - this.startTime;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
onStatusChange(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
onRecordingComplete(listener: RecordingCompleteListener): () => void {
|
||||
this.completeListeners.add(listener);
|
||||
return () => this.completeListeners.delete(listener);
|
||||
}
|
||||
|
||||
private setStatus(next: RecorderStatus): void {
|
||||
if (this.status === next) return;
|
||||
this.status = next;
|
||||
for (const listener of this.statusListeners) {
|
||||
listener(next);
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(): Promise<void> {
|
||||
this.recorder = null;
|
||||
this.startTime = 0;
|
||||
this.setStatus('idle');
|
||||
await audioFocus.release();
|
||||
}
|
||||
}
|
||||
133
app-expo/src/features/voice/segment-outbox.ts
Normal file
133
app-expo/src/features/voice/segment-outbox.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { executeSql, querySql } from '@/core/storage/sqlite';
|
||||
|
||||
import type { SegmentOutboxEntry, SegmentOutboxStatus } from './types';
|
||||
|
||||
const CREATE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS segment_outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
voice_session_id TEXT NOT NULL,
|
||||
segment_index INTEGER NOT NULL,
|
||||
file_uri TEXT NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureTable(): Promise<void> {
|
||||
if (initialized) return;
|
||||
await executeSql(CREATE_TABLE_SQL);
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
function mapRow(row: Record<string, unknown>): SegmentOutboxEntry {
|
||||
return {
|
||||
id: row.id as number,
|
||||
conversationId: row.conversation_id as string,
|
||||
voiceSessionId: row.voice_session_id as string,
|
||||
segmentIndex: row.segment_index as number,
|
||||
fileUri: row.file_uri as string,
|
||||
durationMs: row.duration_ms as number,
|
||||
status: row.status as SegmentOutboxStatus,
|
||||
retryCount: row.retry_count as number,
|
||||
createdAt: row.created_at as number,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-backed outbox for voice segments.
|
||||
* Stores metadata + queue state only — audio files live on the local filesystem.
|
||||
*
|
||||
* State machine: pending → sending → sent | failed
|
||||
* Single writer pattern enforced by serializing all writes through this module.
|
||||
*/
|
||||
export const segmentOutbox = {
|
||||
async enqueue(
|
||||
entry: Omit<
|
||||
SegmentOutboxEntry,
|
||||
'id' | 'status' | 'retryCount' | 'createdAt'
|
||||
>,
|
||||
): Promise<number> {
|
||||
await ensureTable();
|
||||
const result = await executeSql(
|
||||
`INSERT INTO segment_outbox (conversation_id, voice_session_id, segment_index, file_uri, duration_ms, status, retry_count, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
|
||||
[
|
||||
entry.conversationId,
|
||||
entry.voiceSessionId,
|
||||
entry.segmentIndex,
|
||||
entry.fileUri,
|
||||
entry.durationMs,
|
||||
Date.now(),
|
||||
],
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
},
|
||||
|
||||
async getPending(conversationId?: string): Promise<SegmentOutboxEntry[]> {
|
||||
await ensureTable();
|
||||
const sql = conversationId
|
||||
? `SELECT * FROM segment_outbox WHERE status = 'pending' AND conversation_id = ? ORDER BY created_at ASC`
|
||||
: `SELECT * FROM segment_outbox WHERE status = 'pending' ORDER BY created_at ASC`;
|
||||
const params = conversationId ? [conversationId] : [];
|
||||
const rows = await querySql<Record<string, unknown>>(sql, params);
|
||||
return rows.map(mapRow);
|
||||
},
|
||||
|
||||
async markSending(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(
|
||||
`UPDATE segment_outbox SET status = 'sending' WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async markSent(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(`UPDATE segment_outbox SET status = 'sent' WHERE id = ?`, [
|
||||
id,
|
||||
]);
|
||||
},
|
||||
|
||||
async markFailed(id: number): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(
|
||||
`UPDATE segment_outbox SET status = 'failed', retry_count = retry_count + 1 WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
},
|
||||
|
||||
async resetFailed(conversationId?: string): Promise<void> {
|
||||
await ensureTable();
|
||||
const sql = conversationId
|
||||
? `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed' AND conversation_id = ?`
|
||||
: `UPDATE segment_outbox SET status = 'pending' WHERE status = 'failed'`;
|
||||
const params = conversationId ? [conversationId] : [];
|
||||
await executeSql(sql, params);
|
||||
},
|
||||
|
||||
async clearSent(conversationId?: string): Promise<void> {
|
||||
await ensureTable();
|
||||
const sql = conversationId
|
||||
? `DELETE FROM segment_outbox WHERE status = 'sent' AND conversation_id = ?`
|
||||
: `DELETE FROM segment_outbox WHERE status = 'sent'`;
|
||||
const params = conversationId ? [conversationId] : [];
|
||||
await executeSql(sql, params);
|
||||
},
|
||||
|
||||
async clearAll(conversationId: string): Promise<void> {
|
||||
await ensureTable();
|
||||
await executeSql(`DELETE FROM segment_outbox WHERE conversation_id = ?`, [
|
||||
conversationId,
|
||||
]);
|
||||
},
|
||||
|
||||
/** For testing — reset the initialization flag. */
|
||||
_resetForTest(): void {
|
||||
initialized = false;
|
||||
},
|
||||
} as const;
|
||||
52
app-expo/src/features/voice/segmenter.ts
Normal file
52
app-expo/src/features/voice/segmenter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { SegmenterConfig } from './types';
|
||||
|
||||
type SegmentCallback = (segmentIndex: number) => void;
|
||||
|
||||
const DEFAULT_CONFIG: SegmenterConfig = {
|
||||
strategy: 'fixed-duration',
|
||||
fixedDurationMs: 15_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Time-based audio segmenter.
|
||||
* Fires a callback at fixed intervals during recording,
|
||||
* signaling that the current segment should be finalized and sent.
|
||||
*
|
||||
* First phase: fixed-duration only.
|
||||
* Future: silence-based detection as an additional strategy.
|
||||
*/
|
||||
export class AudioSegmenter {
|
||||
private config: SegmenterConfig;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private currentIndex = 0;
|
||||
private onSegment: SegmentCallback;
|
||||
|
||||
constructor(onSegment: SegmentCallback, config?: Partial<SegmenterConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.onSegment = onSegment;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.stop();
|
||||
this.currentIndex = 0;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.onSegment(this.currentIndex);
|
||||
this.currentIndex++;
|
||||
}, this.config.fixedDurationMs);
|
||||
}
|
||||
|
||||
stop(): number {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
const finalIndex = this.currentIndex;
|
||||
this.currentIndex = 0;
|
||||
return finalIndex;
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
}
|
||||
37
app-expo/src/features/voice/types.ts
Normal file
37
app-expo/src/features/voice/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// ─── Recorder state ───
|
||||
|
||||
export type RecorderStatus = 'idle' | 'preparing' | 'recording' | 'stopping';
|
||||
|
||||
// ─── Segmenter ───
|
||||
|
||||
export type SegmentStrategy = 'fixed-duration';
|
||||
|
||||
export interface SegmenterConfig {
|
||||
strategy: SegmentStrategy;
|
||||
fixedDurationMs: number;
|
||||
}
|
||||
|
||||
// ─── Segment outbox ───
|
||||
|
||||
export type SegmentOutboxStatus = 'pending' | 'sending' | 'sent' | 'failed';
|
||||
|
||||
export interface SegmentOutboxEntry {
|
||||
id: number;
|
||||
conversationId: string;
|
||||
voiceSessionId: string;
|
||||
segmentIndex: number;
|
||||
fileUri: string;
|
||||
durationMs: number;
|
||||
status: SegmentOutboxStatus;
|
||||
retryCount: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
// ─── Player ───
|
||||
|
||||
export type PlayerStatus = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
||||
|
||||
export interface PlaybackItem {
|
||||
uri: string;
|
||||
label?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user