@streamplace/components 0.8.11 → 0.8.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/components/chat/chat-message.d.ts.map +1 -1
  2. package/dist/components/chat/chat-message.js +5 -4
  3. package/dist/components/chat/chat-message.js.map +1 -1
  4. package/dist/components/chat/mod-view.d.ts.map +1 -1
  5. package/dist/components/chat/mod-view.js +5 -4
  6. package/dist/components/chat/mod-view.js.map +1 -1
  7. package/dist/components/mobile-player/ui/viewer-context-menu.d.ts.map +1 -1
  8. package/dist/components/mobile-player/ui/viewer-context-menu.js +3 -3
  9. package/dist/components/mobile-player/ui/viewer-context-menu.js.map +1 -1
  10. package/dist/components/share/sharesheet.d.ts.map +1 -1
  11. package/dist/components/share/sharesheet.js +5 -14
  12. package/dist/components/share/sharesheet.js.map +1 -1
  13. package/dist/components/ui/dialog.d.ts +1 -1
  14. package/dist/components/ui/input.d.ts.map +1 -1
  15. package/dist/components/ui/input.js +7 -1
  16. package/dist/components/ui/input.js.map +1 -1
  17. package/dist/components/ui/primitives/input.d.ts.map +1 -1
  18. package/dist/components/ui/primitives/input.js +3 -1
  19. package/dist/components/ui/primitives/input.js.map +1 -1
  20. package/dist/components/ui/text.d.ts +4 -4
  21. package/dist/components/ui/view.d.ts +1 -1
  22. package/dist/i18n/i18n-loader.d.ts +2 -0
  23. package/dist/i18n/i18n-loader.d.ts.map +1 -0
  24. package/dist/i18n/i18n-loader.js +17 -0
  25. package/dist/i18n/i18n-loader.js.map +1 -0
  26. package/dist/i18n/i18n-loader.native.d.ts +2 -0
  27. package/dist/i18n/i18n-loader.native.d.ts.map +1 -0
  28. package/dist/i18n/i18n-loader.native.js +51 -0
  29. package/dist/i18n/i18n-loader.native.js.map +1 -0
  30. package/dist/i18n/i18next-config.d.ts.map +1 -1
  31. package/dist/i18n/i18next-config.js +5 -49
  32. package/dist/i18n/i18next-config.js.map +1 -1
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/livestream-store/chat.d.ts.map +1 -1
  38. package/dist/livestream-store/chat.js +7 -1
  39. package/dist/livestream-store/chat.js.map +1 -1
  40. package/dist/streamplace-provider/poller.d.ts.map +1 -1
  41. package/dist/streamplace-provider/poller.js +2 -0
  42. package/dist/streamplace-provider/poller.js.map +1 -1
  43. package/dist/time-sync/index.d.ts +3 -0
  44. package/dist/time-sync/index.d.ts.map +1 -0
  45. package/dist/time-sync/index.js +15 -0
  46. package/dist/time-sync/index.js.map +1 -0
  47. package/dist/time-sync/time-sync.d.ts +13 -0
  48. package/dist/time-sync/time-sync.d.ts.map +1 -0
  49. package/dist/time-sync/time-sync.js +95 -0
  50. package/dist/time-sync/time-sync.js.map +1 -0
  51. package/dist/time-sync/useTimeSync.d.ts +2 -0
  52. package/dist/time-sync/useTimeSync.d.ts.map +1 -0
  53. package/dist/time-sync/useTimeSync.js +49 -0
  54. package/dist/time-sync/useTimeSync.js.map +1 -0
  55. package/dist/utils/format-handle.d.ts +11 -0
  56. package/dist/utils/format-handle.d.ts.map +1 -0
  57. package/dist/utils/format-handle.js +21 -0
  58. package/dist/utils/format-handle.js.map +1 -0
  59. package/locales/en-US/common.ftl +5 -0
  60. package/locales/en-US/settings.ftl +7 -0
  61. package/locales/es-ES/common.ftl +5 -0
  62. package/locales/es-ES/settings.ftl +7 -0
  63. package/locales/fr-FR/common.ftl +5 -0
  64. package/locales/fr-FR/settings.ftl +7 -0
  65. package/locales/pt-BR/common.ftl +5 -0
  66. package/locales/pt-BR/settings.ftl +7 -0
  67. package/locales/zh-Hant/common.ftl +5 -0
  68. package/locales/zh-Hant/settings.ftl +7 -0
  69. package/node-compile-cache/v22.15.0-x64-efe9a9df-0/37be0eec +0 -0
  70. package/package.json +3 -3
  71. package/src/components/chat/chat-message.tsx +3 -2
  72. package/src/components/chat/mod-view.tsx +5 -3
  73. package/src/components/mobile-player/ui/viewer-context-menu.tsx +5 -3
  74. package/src/components/share/sharesheet.tsx +11 -27
  75. package/src/components/ui/input.tsx +7 -1
  76. package/src/components/ui/primitives/input.tsx +3 -1
  77. package/src/i18n/i18n-loader.native.ts +56 -0
  78. package/src/i18n/i18n-loader.ts +19 -0
  79. package/src/i18n/i18next-config.ts +6 -57
  80. package/src/index.tsx +2 -0
  81. package/src/livestream-store/chat.tsx +7 -1
  82. package/src/streamplace-provider/poller.tsx +3 -0
  83. package/src/time-sync/index.ts +12 -0
  84. package/src/time-sync/time-sync.ts +112 -0
  85. package/src/time-sync/useTimeSync.tsx +58 -0
  86. package/src/utils/format-handle.ts +24 -0
@@ -82,6 +82,7 @@ finish = Finalizar
82
82
 
83
83
  ## Categorias de Navegação
84
84
  about = Sobre
85
+ account = Conta
85
86
  advanced = Avançado
86
87
  danmu = Danmu
87
88
  developer = Desenvolvedor
@@ -97,6 +98,12 @@ refresh = Atualizar
97
98
  save-button = Salvar
98
99
  sign-in = Entrar
99
100
  update = Atualizar
101
+ log-out = Sair
102
+
103
+ ## Configurações da Conta
104
+ account-greeting = Olá, @{ $handle }.
105
+ edit-profile-bluesky = Editar perfil (no Bluesky)
106
+ change-name-color = Mudar cor do nome
100
107
 
101
108
  ## Gerenciamento de Chaves
102
109
  key-management = Gerenciamento de Chaves
@@ -32,6 +32,11 @@ info = 資訊
32
32
  search-placeholder = 搜尋...
33
33
  message-input = 輸入您的訊息...
34
34
 
35
+ ## Authentication & Access
36
+ please-log-in-to-access-this-page = 請登入以存取此頁面
37
+ go-to-settings = 前往設定
38
+ go-back = 返回
39
+
35
40
  ## Demo and Testing
36
41
  welcome-user = 歡迎,{ $username }!
37
42
  notification-count = { $count ->
@@ -83,6 +83,7 @@ finish = 完成
83
83
 
84
84
  ## 導航類別
85
85
  about = 關於
86
+ account = 帳戶
86
87
  advanced = 進階
87
88
  danmu = 彈幕
88
89
  developer = 開發者
@@ -98,6 +99,12 @@ refresh = 重新整理
98
99
  save-button = 儲存
99
100
  sign-in = 登入
100
101
  update = 更新
102
+ log-out = 登出
103
+
104
+ ## 帳戶設定
105
+ account-greeting = 嗨,@{ $handle }。
106
+ edit-profile-bluesky = 編輯個人資料(在 Bluesky)
107
+ change-name-color = 變更名稱顏色
101
108
 
102
109
  ## 金鑰管理
103
110
  key-management = 金鑰管理
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamplace/components",
3
- "version": "0.8.11",
3
+ "version": "0.8.14",
4
4
  "description": "Streamplace React (Native) Components",
5
5
  "main": "dist/index.js",
6
6
  "types": "src/index.tsx",
@@ -72,10 +72,10 @@
72
72
  "scripts": {
73
73
  "build": "tsc",
74
74
  "start": "tsc --watch --preserveWatchOutput",
75
- "prepare": "tsc && node scripts/compile-translations.js",
75
+ "prepare": "node scripts/compile-translations.js && tsc",
76
76
  "i18n:compile": "node scripts/compile-translations.js",
77
77
  "i18n:watch": "nodemon scripts/compile-translations.js --watch locales/**/*.ftl",
78
78
  "i18n:extract": "node scripts/extract-i18n.js"
79
79
  },
80
- "gitHead": "deec4cd1bea51452762e5463462550336057e156"
80
+ "gitHead": "e5835f817342ca5b587bc9bb3e541082bbeac4ea"
81
81
  }
@@ -8,6 +8,7 @@ import { Linking, View } from "react-native";
8
8
  import { ChatMessageViewHydrated } from "streamplace";
9
9
  import { RichtextSegment, segmentize } from "../../lib/facet";
10
10
  import { borders, flex, gap, ml, mr, opacity, pl } from "../../lib/theme/atoms";
11
+ import { formatHandleWithAt } from "../../utils/format-handle";
11
12
  import { atoms, colors, layout } from "../ui";
12
13
 
13
14
  interface Facet {
@@ -138,7 +139,7 @@ export const RenderChatMessage = memo(
138
139
  fontWeight: "thin",
139
140
  }}
140
141
  >
141
- @{(replyTo.author as any).handle}
142
+ {formatHandleWithAt(replyTo.author)}
142
143
  </Text>{" "}
143
144
  <Text
144
145
  style={{
@@ -181,7 +182,7 @@ export const RenderChatMessage = memo(
181
182
  },
182
183
  ]}
183
184
  >
184
- @{item.author.handle}
185
+ {formatHandleWithAt(item.author)}
185
186
  </Text>
186
187
  :{" "}
187
188
  <RichTextMessage
@@ -12,6 +12,7 @@ import { Linking } from "react-native";
12
12
  import { ChatMessageViewHydrated } from "streamplace";
13
13
  import { useDeleteChatMessage } from "../../livestream-store";
14
14
  import { useStreamplaceStore } from "../../streamplace-store";
15
+ import { formatHandle, formatHandleWithAt } from "../../utils/format-handle";
15
16
  import {
16
17
  atoms,
17
18
  DropdownMenu,
@@ -113,7 +114,7 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
113
114
  minute: "2-digit",
114
115
  hour12: false,
115
116
  })}{" "}
116
- @{message.author.handle}: {message.record.text}
117
+ {formatHandleWithAt(message.author)}: {message.record.text}
117
118
  </Text>
118
119
  </View>
119
120
  </DropdownMenuItem>
@@ -159,7 +160,7 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
159
160
  <Text color="destructive">
160
161
  {isBlockLoading
161
162
  ? "Blocking..."
162
- : `Block user @${message.author.handle} from this channel`}
163
+ : `Block user ${formatHandleWithAt(message.author)} from this channel`}
163
164
  </Text>
164
165
  )}
165
166
  </DropdownMenuItem>
@@ -170,7 +171,7 @@ export const ModView = forwardRef<ModViewRef, ModViewProps>(() => {
170
171
  <DropdownMenuItem
171
172
  onPress={() => {
172
173
  Linking.openURL(
173
- `https://${BSKY_FRONTEND_DOMAIN}/profile/${message.author.handle}`,
174
+ `https://${BSKY_FRONTEND_DOMAIN}/profile/${formatHandle(message.author)}`,
174
175
  );
175
176
  }}
176
177
  >
@@ -215,6 +216,7 @@ export function DeleteButton({
215
216
  const toast = useToast();
216
217
  return (
217
218
  <DropdownMenuItem
219
+ closeOnPress={false}
218
220
  onPress={() => {
219
221
  if (!message) return;
220
222
  if (!confirming) {
@@ -4,6 +4,8 @@ import { Image, Linking, Platform, Pressable, View } from "react-native";
4
4
  import {
5
5
  ContentRights,
6
6
  ContentWarnings,
7
+ formatHandle,
8
+ formatHandleWithAt,
7
9
  useAvatars,
8
10
  useLivestreamInfo,
9
11
  zero,
@@ -113,12 +115,12 @@ export function ContextMenu({
113
115
  <Pressable
114
116
  onPress={() => {
115
117
  if (profile?.handle) {
116
- const url = `https://bsky.app/profile/${profile.handle}`;
118
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
117
119
  Linking.openURL(url);
118
120
  }
119
121
  }}
120
122
  >
121
- <Text>@{profile?.handle || "user"}</Text>
123
+ <Text>{profile && formatHandleWithAt(profile)}</Text>
122
124
  </Pressable>
123
125
  {/*{did && profile && (
124
126
  <FollowButton streamerDID={profile?.did} currentUserDID={did} />
@@ -163,7 +165,7 @@ export function ContextMenu({
163
165
  <DropdownMenuItem
164
166
  onPress={() => {
165
167
  if (profile?.handle) {
166
- const url = `https://bsky.app/profile/${profile.handle}`;
168
+ const url = `https://bsky.app/profile/${formatHandle(profile)}`;
167
169
  Linking.openURL(url);
168
170
  }
169
171
  }}
@@ -4,6 +4,7 @@ import { Clipboard, Linking, Platform, View } from "react-native";
4
4
  import { colors } from "../../lib/theme";
5
5
  import { useLivestreamStore } from "../../livestream-store";
6
6
  import { useUrl } from "../../streamplace-store";
7
+ import { formatHandle } from "../../utils/format-handle";
7
8
  import { BlueskyIcon } from "../icons/bluesky-icon";
8
9
  import {
9
10
  DropdownMenu,
@@ -26,12 +27,12 @@ export function ShareSheet({ onShare }: ShareSheetProps = {}) {
26
27
 
27
28
  // Get the current stream URL
28
29
  const getStreamUrl = useCallback(() => {
29
- return url + (profile ? `/@${profile.handle}` : "");
30
+ return url + (profile ? `/${formatHandle(profile)}` : "");
30
31
  }, [profile]);
31
32
 
32
33
  // Get the embed URL
33
34
  const getEmbedUrl = useCallback(() => {
34
- return url + (profile ? `/embed/${profile.handle}` : "");
35
+ return url + (profile ? `/embed/${formatHandle(profile)}` : "");
35
36
  }, [profile]);
36
37
 
37
38
  // Get embed code
@@ -63,31 +64,22 @@ export function ShareSheet({ onShare }: ShareSheetProps = {}) {
63
64
  // Share to Bluesky
64
65
  const shareToBluesky = useCallback(() => {
65
66
  const streamUrl = getStreamUrl();
66
- const text = profile
67
- ? `Check out @${profile.handle} live on Streamplace! ${streamUrl}`
68
- : `Check out this stream on Streamplace! ${streamUrl}`;
67
+ const text =
68
+ profile && profile.handle
69
+ ? `Check out @${profile.handle} live on Streamplace! ${streamUrl}`
70
+ : `Check out this stream on Streamplace! ${streamUrl}`;
69
71
  const blueskyUrl = `https://bsky.app/intent/compose?text=${encodeURIComponent(text)}`;
70
72
  Linking.openURL(blueskyUrl);
71
73
  onShare?.("share_bluesky", true);
72
74
  }, [profile, getStreamUrl, onShare]);
73
75
 
74
- // Share to Twitter/X
75
- const shareToTwitter = useCallback(() => {
76
- const streamUrl = getStreamUrl();
77
- const text = profile
78
- ? `Check out @${profile.handle} live on Streamplace!`
79
- : `Check out this stream on Streamplace!`;
80
- const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(streamUrl)}`;
81
- Linking.openURL(twitterUrl);
82
- onShare?.("share_twitter", true);
83
- }, [profile, getStreamUrl, onShare]);
84
-
85
76
  // Native share (mobile)
86
77
  const nativeShare = useCallback(async () => {
87
78
  const streamUrl = getStreamUrl();
88
- const text = profile
89
- ? `Check out @${profile.handle} live on Streamplace!`
90
- : `Check out this stream on Streamplace!`;
79
+ const text =
80
+ profile && profile.handle
81
+ ? `Check out @${profile.handle} live on Streamplace!`
82
+ : `Check out this stream on Streamplace!`;
91
83
 
92
84
  if (Platform.OS === "web" && navigator.share) {
93
85
  try {
@@ -119,14 +111,6 @@ export function ShareSheet({ onShare }: ShareSheetProps = {}) {
119
111
  <Text>Share to Bluesky</Text>
120
112
  </View>
121
113
  </DropdownMenuItem>
122
- {/* <DropdownMenuItem onPress={shareToTwitter}>
123
- <View
124
- style={{ flexDirection: "row", alignItems: "center", gap: 12 }}
125
- >
126
- <MessageCircle size={20} color={colors.gray[400]} />
127
- <Text>Share to X</Text>
128
- </View>
129
- </DropdownMenuItem> */}
130
114
  {/* navigator isn't on non-web */}
131
115
  {Platform.OS !== "web" || (navigator && (navigator as any).share) ? (
132
116
  <DropdownMenuItem onPress={nativeShare}>
@@ -238,7 +238,13 @@ export const Input = forwardRef<any, InputProps>(
238
238
  error={!!error}
239
239
  onFocus={handleFocus}
240
240
  onBlur={handleBlur}
241
- style={[containerStyles, textStyles, containerStyle, inputStyle]}
241
+ style={[
242
+ containerStyles,
243
+ textStyles,
244
+ containerStyle,
245
+ inputStyle,
246
+ { outline: "none" },
247
+ ]}
242
248
  placeholderTextColor={
243
249
  disabled ? theme.colors.textDisabled : theme.colors.textMuted
244
250
  }
@@ -16,6 +16,7 @@ import {
16
16
  View,
17
17
  ViewProps,
18
18
  } from "react-native";
19
+ import { tokens } from "../../../ui";
19
20
 
20
21
  // Base input primitive interface
21
22
  export interface InputPrimitiveProps extends Omit<TextInputProps, "onChange"> {
@@ -329,7 +330,8 @@ const primitiveStyles = StyleSheet.create({
329
330
  borderWidth: 1,
330
331
  borderColor: "#d1d5db",
331
332
  borderRadius: 8,
332
- backgroundColor: "white",
333
+ boxShadow: "none",
334
+ fontFamily: tokens.fontFamilies.regular,
333
335
  ...Platform.select({
334
336
  ios: {
335
337
  paddingVertical: 12,
@@ -0,0 +1,56 @@
1
+ // Native translation loader - imports translations directly for bundling
2
+ // Metro will use this file for React Native builds
3
+
4
+ // Import all translations directly so they're bundled into the app
5
+ import enUSCommon from "../../public/locales/en-US/common.json";
6
+ import enUSSettings from "../../public/locales/en-US/settings.json";
7
+ import esESCommon from "../../public/locales/es-ES/common.json";
8
+ import esESSettings from "../../public/locales/es-ES/settings.json";
9
+ import frFRCommon from "../../public/locales/fr-FR/common.json";
10
+ import frFRSettings from "../../public/locales/fr-FR/settings.json";
11
+ import ptBRCommon from "../../public/locales/pt-BR/common.json";
12
+ import ptBRSettings from "../../public/locales/pt-BR/settings.json";
13
+ import zhHantCommon from "../../public/locales/zh-Hant/common.json";
14
+ import zhHantSettings from "../../public/locales/zh-Hant/settings.json";
15
+
16
+ const translationMap: Record<string, any> = {
17
+ "en-US/common": enUSCommon,
18
+ "en-US/settings": enUSSettings,
19
+ "pt-BR/common": ptBRCommon,
20
+ "pt-BR/settings": ptBRSettings,
21
+ "es-ES/common": esESCommon,
22
+ "es-ES/settings": esESSettings,
23
+ "zh-Hant/common": zhHantCommon,
24
+ "zh-Hant/settings": zhHantSettings,
25
+ "fr-FR/common": frFRCommon,
26
+ "fr-FR/settings": frFRSettings,
27
+ };
28
+
29
+ export async function loadTranslationData(
30
+ locale: string,
31
+ namespace: string,
32
+ ): Promise<any> {
33
+ // Map base language codes to full locales
34
+ const fullLocale = locale.includes("-")
35
+ ? locale
36
+ : {
37
+ en: "en-US",
38
+ pt: "pt-BR",
39
+ es: "es-ES",
40
+ zh: "zh-Hant",
41
+ fr: "fr-FR",
42
+ }[locale] || locale;
43
+
44
+ const localeNamespaceKey = `${fullLocale}/${namespace}`;
45
+ const translations = translationMap[localeNamespaceKey];
46
+
47
+ if (!translations) {
48
+ throw new Error(`No translation mapping for ${localeNamespaceKey}`);
49
+ }
50
+
51
+ if (!translations || Object.keys(translations).length === 0) {
52
+ throw new Error("No translations found");
53
+ }
54
+
55
+ return translations;
56
+ }
@@ -0,0 +1,19 @@
1
+ // Web translation loader - loads translations via fetch for code splitting
2
+ // Metro will use this file for web builds
3
+
4
+ export async function loadTranslationData(
5
+ locale: string,
6
+ namespace: string,
7
+ ): Promise<any> {
8
+ const response = await fetch(`/locales/${locale}/${namespace}.json`);
9
+ if (!response.ok) {
10
+ throw new Error(`HTTP ${response.status}`);
11
+ }
12
+ const translations = await response.json();
13
+
14
+ if (!translations || Object.keys(translations).length === 0) {
15
+ throw new Error("No translations found");
16
+ }
17
+
18
+ return translations;
19
+ }
@@ -145,68 +145,17 @@ export const I18NEXT_CONFIG = {
145
145
  debug: process.env.NODE_ENV === "development",
146
146
  };
147
147
 
148
- // Translation loading function that loads compiled JSON files per namespace
148
+ // Import platform-specific translation loader
149
+ // Metro will use i18n-loader.native.ts for React Native, i18n-loader.ts for web
150
+ import { loadTranslationData as platformLoadTranslationData } from "./i18n-loader";
151
+
152
+ // Translation loading function with error handling
149
153
  async function loadTranslationData(
150
154
  locale: string,
151
155
  namespace: string,
152
156
  ): Promise<any> {
153
157
  try {
154
- let translations: any = {};
155
-
156
- try {
157
- // For web environments, load from public directory
158
- if (typeof window !== "undefined") {
159
- const response = await fetch(`/locales/${locale}/${namespace}.json`);
160
- if (!response.ok) {
161
- throw new Error(`HTTP ${response.status}`);
162
- }
163
- translations = await response.json();
164
- } else {
165
- // For React Native, use static requires for bundler compatibility
166
- // Map base language codes to full locales
167
- const fullLocale = locale.includes("-")
168
- ? locale
169
- : {
170
- en: "en-US",
171
- pt: "pt-BR",
172
- es: "es-ES",
173
- zh: "zh-Hant",
174
- fr: "fr-FR",
175
- }[locale] || locale;
176
-
177
- // Static requires for React Native bundler
178
- const localeNamespaceKey = `${fullLocale}/${namespace}`;
179
- const translationMap: Record<string, any> = {
180
- "en-US/common": require("../../public/locales/en-US/common.json"),
181
- "pt-BR/common": require("../../public/locales/pt-BR/common.json"),
182
- "es-ES/common": require("../../public/locales/es-ES/common.json"),
183
- "zh-Hant/common": require("../../public/locales/zh-Hant/common.json"),
184
- "fr-FR/common": require("../../public/locales/fr-FR/common.json"),
185
- "en-US/settings": require("../../public/locales/en-US/settings.json"),
186
- "pt-BR/settings": require("../../public/locales/pt-BR/settings.json"),
187
- "es-ES/settings": require("../../public/locales/es-ES/settings.json"),
188
- "zh-Hant/settings": require("../../public/locales/zh-Hant/settings.json"),
189
- "fr-FR/settings": require("../../public/locales/fr-FR/settings.json"),
190
- };
191
-
192
- translations = translationMap[localeNamespaceKey];
193
-
194
- if (!translations) {
195
- throw new Error(
196
- `No static translation mapping for ${localeNamespaceKey}`,
197
- );
198
- }
199
- }
200
- } catch (loadError: any) {
201
- throw new Error(
202
- `Failed to load ${namespace} translations for ${locale}: ${loadError.message}`,
203
- );
204
- }
205
-
206
- if (!translations || Object.keys(translations).length === 0) {
207
- throw new Error("No translations found in file");
208
- }
209
-
158
+ const translations = await platformLoadTranslationData(locale, namespace);
210
159
  return translations;
211
160
  } catch (error: any) {
212
161
  console.error(
package/src/index.tsx CHANGED
@@ -37,6 +37,8 @@ export * from "./components/chat/system-message";
37
37
  export { default as VideoRetry } from "./components/mobile-player/video-retry";
38
38
  export * from "./lib/system-messages";
39
39
 
40
+ export * from "./utils/format-handle";
41
+
40
42
  export { DanmuOverlay } from "./components/danmu/danmu-overlay";
41
43
  export { DanmuOverlayOBS } from "./components/danmu/danmu-overlay-obs";
42
44
 
@@ -215,7 +215,13 @@ export const reduceChatIncremental = (
215
215
  for (const msg of newMessages) {
216
216
  if (msg.deleted) {
217
217
  hasChanges = true;
218
- removedKeys.add(msg.uri);
218
+ // find and remove the message from the index
219
+ for (const [key, message] of Object.entries(newChatIndex)) {
220
+ if (message.uri === msg.uri) {
221
+ delete newChatIndex[key];
222
+ removedKeys.add(key);
223
+ }
224
+ }
219
225
  }
220
226
  }
221
227
  newMessages = newMessages.filter((msg) => msg.deleted !== true);
@@ -7,6 +7,7 @@ import {
7
7
  useStreamplaceStore,
8
8
  } from "../streamplace-store";
9
9
  import { usePDSAgent } from "../streamplace-store/xrpc";
10
+ import { useTimeSync } from "../time-sync";
10
11
 
11
12
  export default function Poller({ children }: { children: React.ReactNode }) {
12
13
  const url = useStreamplaceStore((state) => state.url);
@@ -19,6 +20,8 @@ export default function Poller({ children }: { children: React.ReactNode }) {
19
20
  (state) => state.liveUsersRefresh,
20
21
  );
21
22
 
23
+ useTimeSync();
24
+
22
25
  useEffect(() => {
23
26
  if (pdsAgent && did) {
24
27
  getChatProfile();
@@ -0,0 +1,12 @@
1
+ export {
2
+ checkClockDrift,
3
+ getSyncedDate,
4
+ getSystemDate,
5
+ getSystemTime,
6
+ getTimeOffset,
7
+ initializeTimeSync,
8
+ setTimeOffset,
9
+ syncTimeWithServer,
10
+ } from "./time-sync";
11
+
12
+ export { useTimeSync } from "./useTimeSync";
@@ -0,0 +1,112 @@
1
+ import { Platform } from "react-native";
2
+
3
+ let timeOffset = 0;
4
+ let hasWarned = false;
5
+ let OriginalDate: DateConstructor = Date;
6
+
7
+ const CLOCK_DRIFT_THRESHOLD_MS = 5000; // 5 seconds
8
+
9
+ export function getTimeOffset(): number {
10
+ return timeOffset;
11
+ }
12
+
13
+ export function setTimeOffset(offset: number): void {
14
+ timeOffset = offset;
15
+ }
16
+
17
+ export function checkClockDrift(serverTime: string): {
18
+ hasDrift: boolean;
19
+ driftMs: number;
20
+ driftSeconds: number;
21
+ } {
22
+ const serverDate = new Date(serverTime);
23
+ const clientDate = new Date();
24
+ const drift = Math.abs(serverDate.getTime() - clientDate.getTime());
25
+
26
+ if (drift > CLOCK_DRIFT_THRESHOLD_MS) {
27
+ const driftSeconds = Math.round(drift / 1000);
28
+ if (!hasWarned) {
29
+ hasWarned = true;
30
+ console.warn(
31
+ `clock drift detected: ${driftSeconds}s difference from server time. ` +
32
+ `this may cause issues with time-sensitive operations. ` +
33
+ `please sync your system clock.`,
34
+ );
35
+ }
36
+ return { hasDrift: true, driftMs: drift, driftSeconds };
37
+ } else {
38
+ return {
39
+ hasDrift: false,
40
+ driftMs: drift,
41
+ driftSeconds: Math.round(drift / 1000),
42
+ };
43
+ }
44
+ }
45
+
46
+ export function syncTimeWithServer(
47
+ serverTime: string,
48
+ networkLatencyMs: number,
49
+ ): void {
50
+ const serverDate = new OriginalDate(serverTime);
51
+ const clientDate = new OriginalDate();
52
+ const offset = serverDate.getTime() - clientDate.getTime() - networkLatencyMs;
53
+
54
+ setTimeOffset(offset);
55
+ }
56
+
57
+ export function getSyncedDate(): Date {
58
+ const now = new Date();
59
+ if (timeOffset !== 0) {
60
+ return new Date(now.getTime() + timeOffset);
61
+ }
62
+ return now;
63
+ }
64
+
65
+ export function getSystemDate(): Date {
66
+ return new OriginalDate();
67
+ }
68
+
69
+ export function getSystemTime(): number {
70
+ return OriginalDate.now();
71
+ }
72
+
73
+ export function initializeTimeSync(): void {
74
+ if (Platform.OS !== "web") {
75
+ return;
76
+ }
77
+
78
+ // store original Date
79
+ OriginalDate = Date;
80
+ const OriginalDatePrototype = OriginalDate.prototype;
81
+
82
+ // create patched Date constructor
83
+ function PatchedDate(this: any, ...args: any[]): any {
84
+ // If called as a function (no `new`), forward to original Date to get the string form
85
+ if (!(this instanceof PatchedDate)) {
86
+ return OriginalDate.apply(undefined, args as any);
87
+ }
88
+
89
+ // If called as a constructor, construct a Date with synced time when no args provided
90
+ if (args.length === 0) {
91
+ const syncedTime = OriginalDate.now() + timeOffset;
92
+ return Reflect.construct(OriginalDate, [syncedTime], PatchedDate);
93
+ }
94
+
95
+ // Otherwise construct with the provided arguments
96
+ return Reflect.construct(OriginalDate, args, PatchedDate);
97
+ }
98
+
99
+ // copy static methods
100
+ PatchedDate.now = function (): number {
101
+ return OriginalDate.now() + timeOffset;
102
+ };
103
+
104
+ PatchedDate.parse = OriginalDate.parse;
105
+ PatchedDate.UTC = OriginalDate.UTC;
106
+
107
+ // copy prototype
108
+ PatchedDate.prototype = OriginalDatePrototype;
109
+
110
+ // replace global Date
111
+ (globalThis as any).Date = PatchedDate;
112
+ }
@@ -0,0 +1,58 @@
1
+ import { TriangleAlert } from "lucide-react-native";
2
+ import { useEffect, useRef } from "react";
3
+ import { Platform } from "react-native";
4
+ import { StreamplaceAgent } from "streamplace";
5
+ import { useToast } from "../components/ui/toast";
6
+ import { useUrl } from "../streamplace-store/streamplace-store";
7
+ import { checkClockDrift, syncTimeWithServer } from "./time-sync";
8
+
9
+ export function useTimeSync() {
10
+ const url = useUrl();
11
+ const t = useToast();
12
+ const hasShownWarning = useRef(false);
13
+
14
+ useEffect(() => {
15
+ const checkTime = async () => {
16
+ if (Platform.OS !== "web") {
17
+ return;
18
+ }
19
+ try {
20
+ const agent = new StreamplaceAgent(url);
21
+ const start = new Date().getTime();
22
+ const response = await agent.place.stream.server.getServerTime();
23
+ const roundTripLatency = new Date().getTime() - start;
24
+ const serverTime = response.data.serverTime;
25
+
26
+ // always sync with server time
27
+ syncTimeWithServer(serverTime, roundTripLatency / 2);
28
+
29
+ const driftInfo = checkClockDrift(serverTime);
30
+
31
+ // only show warning if drift is significant
32
+ if (driftInfo.hasDrift && !hasShownWarning.current) {
33
+ hasShownWarning.current = true;
34
+ t.show(
35
+ "Clock drift detected!",
36
+ `Your device clock is ${driftInfo.driftSeconds}s off from server time. Please sync your system clock to avoid issues.`,
37
+ {
38
+ variant: "info",
39
+ iconLeft: TriangleAlert,
40
+ duration: 25,
41
+ },
42
+ );
43
+ console.log(
44
+ `time sync applied: offset ${driftInfo.driftMs}ms. Date() calls will now use server time.`,
45
+ );
46
+ }
47
+ } catch (error) {
48
+ console.error("failed to sync time with server:", error);
49
+ }
50
+ };
51
+
52
+ checkTime();
53
+
54
+ const interval = setInterval(checkTime, 1800000); // every 30m
55
+
56
+ return () => clearInterval(interval);
57
+ }, [url, t]);
58
+ }