feat: enhance mobile voice build and UI layout
- Updated AGENTS.md with new build commands for development and production. - Added a new script in package.json for starting Expo with a specific hostname. - Improved layout handling in DictationScreen by adding dynamic height calculations for dropdown menus and footers. - Introduced new state variables to manage menu list heights and footer heights for better UI responsiveness. - Enhanced QR code generation for mobile pairing in the serve command, allowing for a connect QR option without starting the server.pull/19545/head
parent
c90640e0e1
commit
4d30ad1e7c
|
|
@ -175,3 +175,9 @@ Example shape:
|
|||
- If behavior could break startup, run `bunx expo export --platform ios --clear`.
|
||||
- Confirm no accidental config side effects were introduced.
|
||||
- Summarize what was verified on-device vs only in tooling.
|
||||
|
||||
|
||||
- Dev build (internal/dev client):
|
||||
- bunx eas build --profile development --platform ios
|
||||
- Production build + auto-submit:
|
||||
- bunx eas build --profile production --platform ios --auto-submit
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"expo:start": "REACT_NATIVE_PACKAGER_HOSTNAME=exos.husky-tilapia.ts.net expo start --dev-client --clear --host lan",
|
||||
"relay": "echo 'Use packages/apn-relay for APNs relay server'",
|
||||
"relay:legacy": "node ./relay/opencode-relay.mjs",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ const WAVEFORM_ROWS = 5
|
|||
const WAVEFORM_CELL_SIZE = 8
|
||||
const WAVEFORM_CELL_GAP = 2
|
||||
const DROPDOWN_VISIBLE_ROWS = 6
|
||||
const DROPDOWN_ROW_HEIGHT = 42
|
||||
const SERVER_MENU_SECTION_HEIGHT = 56
|
||||
const SERVER_MENU_ENTRY_HEIGHT = 36
|
||||
const SERVER_MENU_FOOTER_HEIGHT = 28
|
||||
// If the press duration is shorter than this, treat it as a tap (toggle)
|
||||
const TAP_THRESHOLD_MS = 300
|
||||
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
|
||||
|
|
@ -591,6 +595,10 @@ export default function DictationScreen() {
|
|||
const [readerModeRendered, setReaderModeRendered] = useState(false)
|
||||
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
|
||||
const [dropdownRenderMode, setDropdownRenderMode] = useState<Exclude<DropdownMode, "none">>("server")
|
||||
const [serverMenuListHeight, setServerMenuListHeight] = useState(0)
|
||||
const [sessionMenuListHeight, setSessionMenuListHeight] = useState(0)
|
||||
const [serverMenuFooterHeight, setServerMenuFooterHeight] = useState(0)
|
||||
const [sessionMenuFooterHeight, setSessionMenuFooterHeight] = useState(0)
|
||||
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
|
||||
const [scanOpen, setScanOpen] = useState(false)
|
||||
const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
|
||||
|
|
@ -633,6 +641,8 @@ export default function DictationScreen() {
|
|||
setDropdownMode("none")
|
||||
}, [])
|
||||
|
||||
const discoveryEnabled = onboardingComplete && localNetworkPermissionState !== "denied" && dropdownMode === "server"
|
||||
|
||||
const {
|
||||
servers,
|
||||
serversRef,
|
||||
|
|
@ -655,7 +665,7 @@ export default function DictationScreen() {
|
|||
|
||||
const { discoveredServers, discoveryStatus, discoveryError, discoveryAvailable, refreshDiscovery } = useMdnsDiscovery(
|
||||
{
|
||||
enabled: onboardingComplete && localNetworkPermissionState !== "denied",
|
||||
enabled: discoveryEnabled,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -2000,16 +2010,28 @@ export default function DictationScreen() {
|
|||
],
|
||||
}))
|
||||
|
||||
const serverMenuRows = 2 + Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
|
||||
const menuRows = effectiveDropdownMode === "server" ? serverMenuRows : Math.max(activeServer?.sessions.length ?? 0, 1)
|
||||
const expandedRowsHeight = Math.min(menuRows, DROPDOWN_VISIBLE_ROWS) * 42
|
||||
const maxDropdownListHeight = DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT
|
||||
const serverMenuEntries = Math.max(servers.length, 1) + Math.max(discoveredServerOptions.length, 1)
|
||||
const estimatedServerMenuRowsHeight = Math.min(
|
||||
SERVER_MENU_SECTION_HEIGHT + serverMenuEntries * SERVER_MENU_ENTRY_HEIGHT,
|
||||
maxDropdownListHeight,
|
||||
)
|
||||
const sessionMenuRows = Math.max(activeServer?.sessions.length ?? 0, 1)
|
||||
const estimatedSessionMenuRowsHeight = Math.min(sessionMenuRows, DROPDOWN_VISIBLE_ROWS) * DROPDOWN_ROW_HEIGHT
|
||||
const serverMenuRowsHeight = Math.min(serverMenuListHeight || estimatedServerMenuRowsHeight, maxDropdownListHeight)
|
||||
const sessionMenuRowsHeight = Math.min(sessionMenuListHeight || estimatedSessionMenuRowsHeight, maxDropdownListHeight)
|
||||
const expandedRowsHeight = effectiveDropdownMode === "server" ? serverMenuRowsHeight : sessionMenuRowsHeight
|
||||
|
||||
const estimatedSessionFooterHeight = sessionCreationChoiceCount === 2 ? 72 : sessionCreationChoiceCount === 1 ? 38 : 8
|
||||
|
||||
const measuredServerFooterHeight = serverMenuFooterHeight || SERVER_MENU_FOOTER_HEIGHT
|
||||
const measuredSessionFooterHeight = sessionMenuFooterHeight || estimatedSessionFooterHeight
|
||||
|
||||
const dropdownFooterExtraHeight =
|
||||
effectiveDropdownMode === "server"
|
||||
? 38
|
||||
: sessionCreationChoiceCount === 2
|
||||
? 72
|
||||
: sessionCreationChoiceCount === 1
|
||||
? 38
|
||||
? measuredServerFooterHeight
|
||||
: showSessionCreationChoices
|
||||
? measuredSessionFooterHeight
|
||||
: 8
|
||||
const expandedHeaderHeight = 51 + 12 + expandedRowsHeight + dropdownFooterExtraHeight
|
||||
|
||||
|
|
@ -2104,6 +2126,26 @@ export default function DictationScreen() {
|
|||
setWaveformLevels(next)
|
||||
}, [])
|
||||
|
||||
const handleServerMenuListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||
setServerMenuListHeight((prev) => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
const handleSessionMenuListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||
setSessionMenuListHeight((prev) => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
const handleServerMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||
setServerMenuFooterHeight((prev) => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
const handleSessionMenuFooterLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const next = Math.ceil(event.nativeEvent.layout.height)
|
||||
setSessionMenuFooterHeight((prev) => (prev === next ? prev : next))
|
||||
}, [])
|
||||
|
||||
const toggleServerMenu = useCallback(() => {
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
setDropdownMode((prev) => {
|
||||
|
|
@ -2764,7 +2806,7 @@ export default function DictationScreen() {
|
|||
bounces={false}
|
||||
>
|
||||
{effectiveDropdownMode === "server" ? (
|
||||
<>
|
||||
<View onLayout={handleServerMenuListLayout}>
|
||||
<Text style={styles.serverGroupLabel}>Saved:</Text>
|
||||
|
||||
{servers.length === 0 ? (
|
||||
|
|
@ -2833,9 +2875,9 @@ export default function DictationScreen() {
|
|||
{discoveryError}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
</View>
|
||||
) : activeServer ? (
|
||||
<>
|
||||
<View onLayout={handleSessionMenuListLayout}>
|
||||
{activeSession ? (
|
||||
<>
|
||||
<View style={styles.currentSessionSummary}>
|
||||
|
|
@ -2890,18 +2932,20 @@ export default function DictationScreen() {
|
|||
</Pressable>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.serverEmptyText}>Select a server first</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{effectiveDropdownMode === "server" ? (
|
||||
<View onLayout={handleServerMenuFooterLayout}>
|
||||
<Pressable onPress={() => void handleStartScan()} style={styles.addServerButton}>
|
||||
<Text style={styles.addServerButtonText}>Add server by scanning QR code</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : effectiveDropdownMode === "session" && activeServer?.status === "online" ? (
|
||||
<View style={styles.sessionMenuActions}>
|
||||
<View style={styles.sessionMenuActions} onLayout={handleSessionMenuFooterLayout}>
|
||||
{activeSession ? (
|
||||
<Pressable
|
||||
onPress={handleCreateSessionLikeCurrent}
|
||||
|
|
@ -3887,14 +3931,14 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
serverMenuInline: {
|
||||
marginTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingBottom: 2,
|
||||
gap: 4,
|
||||
},
|
||||
dropdownListViewport: {
|
||||
maxHeight: DROPDOWN_VISIBLE_ROWS * 42,
|
||||
maxHeight: DROPDOWN_VISIBLE_ROWS * DROPDOWN_ROW_HEIGHT,
|
||||
},
|
||||
dropdownListContent: {
|
||||
paddingBottom: 2,
|
||||
paddingBottom: 0,
|
||||
},
|
||||
currentSessionSummary: {
|
||||
paddingHorizontal: 4,
|
||||
|
|
@ -4026,10 +4070,11 @@ const styles = StyleSheet.create({
|
|||
fontWeight: "700",
|
||||
},
|
||||
addServerButton: {
|
||||
marginTop: 10,
|
||||
marginTop: 4,
|
||||
alignSelf: "center",
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
addServerButtonText: {
|
||||
color: "#B8BDC9",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ import * as QRCode from "qrcode"
|
|||
|
||||
const log = Log.create({ service: "serve" })
|
||||
|
||||
type PairPayload = {
|
||||
v: 1
|
||||
serverID?: string
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
function ipTier(address: string): number {
|
||||
const parts = address.split(".")
|
||||
if (parts.length !== 4) return 4
|
||||
|
|
@ -36,9 +44,10 @@ function advertiseURL(input: string, port: number): string | undefined {
|
|||
if (!raw) return
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
||||
const hasScheme = raw.includes("://")
|
||||
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||
if (!parsed.hostname) return
|
||||
if (!parsed.port) {
|
||||
if (!parsed.port && !hasScheme) {
|
||||
parsed.port = String(port)
|
||||
}
|
||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||
|
|
@ -47,7 +56,7 @@ function advertiseURL(input: string, port: number): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function hosts(hostname: string, port: number, advertised: string[] = []) {
|
||||
function hosts(hostname: string, port: number, advertised: string[] = [], includeLocal = true) {
|
||||
const seen = new Set<string>()
|
||||
const preferred: string[] = []
|
||||
const entries: Array<{ url: string; tier: number }> = []
|
||||
|
|
@ -72,12 +81,15 @@ function hosts(hostname: string, port: number, advertised: string[] = []) {
|
|||
|
||||
advertised.forEach(addPreferred)
|
||||
|
||||
if (includeLocal) {
|
||||
add(hostname)
|
||||
Object.values(os.networkInterfaces())
|
||||
.flatMap((item) => item ?? [])
|
||||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||
.map((item) => item.address)
|
||||
.forEach(add)
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.tier - b.tier)
|
||||
return [...preferred, ...entries.map((item) => item.url)]
|
||||
}
|
||||
|
|
@ -86,84 +98,16 @@ function pairLink(pair: unknown) {
|
|||
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
|
||||
}
|
||||
|
||||
function pairServerID(input: { relayURL: string; relaySecret: string }) {
|
||||
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
function secretHash(input: string) {
|
||||
if (!input) return "none"
|
||||
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
|
||||
}
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.option("relay-url", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay URL",
|
||||
})
|
||||
.option("relay-secret", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay secret",
|
||||
})
|
||||
.option("advertise-host", {
|
||||
type: "string",
|
||||
array: true,
|
||||
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
const relayURL = (
|
||||
args["relay-url"] ??
|
||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||
"https://apn.dev.opencode.ai"
|
||||
).trim()
|
||||
const advertiseHostArg = args["advertise-host"]
|
||||
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
|
||||
? advertiseHostArg
|
||||
: typeof advertiseHostArg === "string"
|
||||
? [advertiseHostArg]
|
||||
: []
|
||||
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
|
||||
|
||||
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||
const relaySecret = input || randomBytes(18).toString("base64url")
|
||||
if (!input) {
|
||||
console.log("experimental push relay secret generated")
|
||||
console.log(
|
||||
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
|
||||
)
|
||||
}
|
||||
if (relayURL && relaySecret) {
|
||||
const host = server.hostname ?? opts.hostname
|
||||
const port = server.port || opts.port || 4096
|
||||
const started = PushRelay.start({
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hostname: host,
|
||||
port,
|
||||
advertiseHosts,
|
||||
})
|
||||
const pair = started ??
|
||||
PushRelay.pair() ?? {
|
||||
v: 1 as const,
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: hosts(host, port, advertiseHosts),
|
||||
}
|
||||
if (!started) {
|
||||
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
||||
}
|
||||
if (pair) {
|
||||
console.log("experimental push relay enabled")
|
||||
async function printPairQR(pair: PairPayload) {
|
||||
const link = pairLink(pair)
|
||||
const qrConfig = {
|
||||
type: "terminal" as const,
|
||||
|
|
@ -186,6 +130,116 @@ export const ServeCommand = cmd({
|
|||
console.log("scan qr code in mobile app or phone camera")
|
||||
console.log(code)
|
||||
}
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.option("relay-url", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay URL",
|
||||
})
|
||||
.option("relay-secret", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay secret",
|
||||
})
|
||||
.option("advertise-host", {
|
||||
type: "string",
|
||||
array: true,
|
||||
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
||||
})
|
||||
.option("connect-qr", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "print mobile connect QR and exit without starting the server",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const relayURL = (
|
||||
args["relay-url"] ??
|
||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||
"https://apn.dev.opencode.ai"
|
||||
).trim()
|
||||
const advertiseHostArg = args["advertise-host"]
|
||||
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
|
||||
? advertiseHostArg
|
||||
: typeof advertiseHostArg === "string"
|
||||
? [advertiseHostArg]
|
||||
: []
|
||||
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
|
||||
|
||||
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||
const relaySecret = input || randomBytes(18).toString("base64url")
|
||||
const connectQR = Boolean(args["connect-qr"])
|
||||
|
||||
if (connectQR) {
|
||||
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
|
||||
if (!pairHosts.length) {
|
||||
console.log("connect qr mode requires at least one valid --advertise-host value")
|
||||
return
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
console.log("experimental push relay secret generated")
|
||||
console.log(
|
||||
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
|
||||
)
|
||||
}
|
||||
|
||||
console.log("printing connect qr without starting the server")
|
||||
await printPairQR({
|
||||
v: 1,
|
||||
serverID: pairServerID({ relayURL, relaySecret }),
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: pairHosts,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
|
||||
const server = Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
if (!input) {
|
||||
console.log("experimental push relay secret generated")
|
||||
console.log(
|
||||
"set --relay-secret or OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET to keep push registrations stable across server restarts",
|
||||
)
|
||||
}
|
||||
if (relayURL && relaySecret) {
|
||||
const host = server.hostname ?? opts.hostname
|
||||
const port = server.port || opts.port || 4096
|
||||
const started = PushRelay.start({
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hostname: host,
|
||||
port,
|
||||
advertiseHosts,
|
||||
})
|
||||
const pair = started ??
|
||||
PushRelay.pair() ?? {
|
||||
v: 1 as const,
|
||||
serverID: pairServerID({ relayURL, relaySecret }),
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: hosts(host, port, advertiseHosts),
|
||||
}
|
||||
if (!started) {
|
||||
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
||||
}
|
||||
if (pair) {
|
||||
console.log("experimental push relay enabled")
|
||||
await printPairQR(pair)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
|
|
|
|||
|
|
@ -105,9 +105,10 @@ function advertiseURL(input: string, port: number): string | undefined {
|
|||
if (!raw) return
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
||||
const hasScheme = raw.includes("://")
|
||||
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||
if (!parsed.hostname) return
|
||||
if (!parsed.port) {
|
||||
if (!parsed.port && !hasScheme) {
|
||||
parsed.port = String(port)
|
||||
}
|
||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||
|
|
@ -205,6 +206,22 @@ function fallback(input: Type) {
|
|||
return "OpenCode reported an error for your session."
|
||||
}
|
||||
|
||||
function titlePrefix(input: Type) {
|
||||
if (input === "permission") return "Action Needed"
|
||||
if (input === "error") return "Error"
|
||||
return
|
||||
}
|
||||
|
||||
function titleForType(input: Type, title: string) {
|
||||
const next = text(title)
|
||||
if (!next) return next
|
||||
const prefix = titlePrefix(input)
|
||||
if (!prefix) return next
|
||||
const tagged = `${prefix}:`
|
||||
if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next
|
||||
return `${tagged} ${next}`
|
||||
}
|
||||
|
||||
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
|
||||
const out: Notify = {
|
||||
type: input.type,
|
||||
|
|
@ -252,6 +269,7 @@ async function notify(input: { type: Type; sessionID: string }): Promise<Notify>
|
|||
}
|
||||
|
||||
if (!out.title) out.title = `Session ${input.type}`
|
||||
out.title = titleForType(input.type, out.title)
|
||||
if (!out.body) out.body = fallback(input.type)
|
||||
return out
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue